feat(frontend): Payload Local API + hu/en locale routing (MITHOME-91/114)
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
CI Pipeline with Test Management / 🧪 Run Tests & Generate Reports (push) Waiting to run
CI Pipeline with Test Management / 🐳 Docker Integration Tests (push) Blocked by required conditions
CI Pipeline with Test Management / 🏗️ Build Docker Image (push) Blocked by required conditions
CI Pipeline with Test Management / 📊 Generate Test Summary (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🧪 Run Tests & Generate Reports (push) Waiting to run
Test Reporting & Gherkin Analysis / 📊 Analyze Test Coverage (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / 🔄 Sync with Linear (push) Blocked by required conditions
Test Reporting & Gherkin Analysis / ⚡ Performance Monitoring (push) Blocked by required conditions
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 <title> 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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
594865ea9a
commit
d6f3dda9e5
@@ -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} />
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Executable
+41
@@ -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} />
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { content } from '@/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.',
|
||||
}
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
const pageContent = content.pages.adatvedelem
|
||||
|
||||
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={{
|
||||
// Ehelyett a valódi alkalmazásban javasolt egy Markdown parser, de az egyszerűség kedvéért a sima sortöréseket lekezeljük
|
||||
__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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { siteConfig } from '@/config/site'
|
||||
import { content } from '@/content'
|
||||
import { useState } from 'react'
|
||||
|
||||
const { contact: pageContent } = content.pages
|
||||
|
||||
export default function ContactPage() {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
gdprConsent: false
|
||||
})
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
newErrors.name = pageContent.form.fields.name.error
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = pageContent.form.fields.email.errorRequired
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = pageContent.form.fields.email.errorInvalid
|
||||
}
|
||||
|
||||
if (!formData.subject.trim()) {
|
||||
newErrors.subject = pageContent.form.fields.subject.error
|
||||
}
|
||||
|
||||
if (!formData.message.trim()) {
|
||||
newErrors.message = pageContent.form.fields.message.errorRequired
|
||||
} else if (formData.message.trim().length < 10) {
|
||||
newErrors.message = pageContent.form.fields.message.errorMinLength
|
||||
}
|
||||
|
||||
if (!formData.gdprConsent) {
|
||||
newErrors.gdprConsent = pageContent.form.fields.gdpr.error
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!validateForm()) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setSubmitStatus('idle')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
setSubmitStatus('success')
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
gdprConsent: false
|
||||
})
|
||||
} else {
|
||||
setSubmitStatus('error')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error)
|
||||
setSubmitStatus('error')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const { name, value, type } = e.target
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value
|
||||
}))
|
||||
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: '' }))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-16 py-8">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-r from-blue-50 to-indigo-50 py-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||
{pageContent.hero.title}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 leading-relaxed">
|
||||
{pageContent.hero.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Contact Form */}
|
||||
<div>
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
{pageContent.form.title}
|
||||
</h2>
|
||||
|
||||
{submitStatus === 'success' && (
|
||||
<div className="mb-6 p-4 bg-green-50 border border-green-200 rounded-md">
|
||||
<p className="text-green-800">
|
||||
✅ {pageContent.form.successMessage}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{pageContent.form.fields.name.label} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.name ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder={pageContent.form.fields.name.placeholder}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-sm text-red-600">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{pageContent.form.fields.email.label} *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.email ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder={pageContent.form.fields.email.placeholder}
|
||||
/>
|
||||
{errors.email && <p className="mt-1 text-sm text-red-600">{errors.email}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="subject" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{pageContent.form.fields.subject.label} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={handleInputChange}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.subject ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder={pageContent.form.fields.subject.placeholder}
|
||||
/>
|
||||
{errors.subject && <p className="mt-1 text-sm text-red-600">{errors.subject}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{pageContent.form.fields.message.label} *
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={5}
|
||||
value={formData.message}
|
||||
onChange={handleInputChange}
|
||||
className={`w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||||
errors.message ? 'border-red-300' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder={pageContent.form.fields.message.placeholder}
|
||||
/>
|
||||
{errors.message && <p className="mt-1 text-sm text-red-600">{errors.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="gdprConsent"
|
||||
checked={formData.gdprConsent}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 h-4 w-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<span
|
||||
className="text-sm text-gray-700"
|
||||
dangerouslySetInnerHTML={{ __html: pageContent.form.fields.gdpr.label + ' *' }}
|
||||
/>
|
||||
</label>
|
||||
{errors.gdprConsent && <p className="mt-1 text-sm text-red-600">{errors.gdprConsent}</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white font-medium py-3 px-4 rounded-md transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
{isSubmitting ? pageContent.form.submittingButton : pageContent.form.submitButton}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-8">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
||||
{pageContent.info.title}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg">✉️</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 mb-1">{pageContent.info.email.title}</h3>
|
||||
<a
|
||||
href={`mailto:${siteConfig.contact.email}`}
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{siteConfig.contact.email}
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{pageContent.info.email.responseTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg">🏢</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-lg">🌐</span>
|
||||
</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}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:text-blue-700"
|
||||
>
|
||||
{pageContent.info.webmail.linkText}
|
||||
</a>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{pageContent.info.webmail.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div className="bg-gray-50 rounded-xl p-8">
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-6">
|
||||
{pageContent.faq.title}
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Executable → Regular
+6
-303
@@ -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))
|
||||
}
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { siteConfig } from '@/config/site'
|
||||
import { content } from '@/content'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
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`,
|
||||
},
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="space-y-0">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-hero py-16 lg:py-24 relative overflow-hidden">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div
|
||||
className="absolute -top-20 -right-20 w-60 h-60 rounded-full opacity-20 animate-pulse-slow"
|
||||
style={{ background: 'var(--color-primary-300)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center relative z-10">
|
||||
<h1
|
||||
className="text-4xl md:text-5xl font-bold mb-6 animate-fade-in-up"
|
||||
style={{ color: 'var(--color-foreground)' }}
|
||||
>
|
||||
{pageContent.hero.title}
|
||||
</h1>
|
||||
<p
|
||||
className="text-xl leading-relaxed animate-fade-in-up"
|
||||
style={{ color: 'var(--color-foreground-muted)', animationDelay: '100ms' }}
|
||||
>
|
||||
{pageContent.hero.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Story Section */}
|
||||
<section
|
||||
className="py-16"
|
||||
style={{ background: 'var(--color-background)' }}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="prose prose-lg mx-auto">
|
||||
<h2
|
||||
className="text-3xl font-bold mb-6"
|
||||
style={{ color: 'var(--color-foreground)' }}
|
||||
>
|
||||
{pageContent.story.title}
|
||||
</h2>
|
||||
|
||||
<div
|
||||
className="space-y-6 leading-relaxed"
|
||||
style={{ color: 'var(--color-foreground-secondary)' }}
|
||||
>
|
||||
{pageContent.story.paragraphs.map((paragraph, index) => (
|
||||
<p
|
||||
key={index}
|
||||
dangerouslySetInnerHTML={{ __html: paragraph }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission & Values */}
|
||||
<section
|
||||
className="py-16"
|
||||
style={{ background: 'var(--color-background-secondary)' }}
|
||||
>
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2
|
||||
className="text-3xl font-bold mb-4"
|
||||
style={{ color: 'var(--color-foreground)' }}
|
||||
>
|
||||
{pageContent.mission.title}
|
||||
</h2>
|
||||
<p
|
||||
className="text-lg max-w-3xl mx-auto"
|
||||
style={{ color: 'var(--color-foreground-muted)' }}
|
||||
>
|
||||
{pageContent.mission.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 stagger-children">
|
||||
{pageContent.mission.values.map((item, index) => (
|
||||
<div
|
||||
key={item.title}
|
||||
className="group text-center p-8 rounded-xl 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">{item.icon}</span>
|
||||
</div>
|
||||
<h3
|
||||
className="text-xl font-semibold mb-3"
|
||||
style={{ color: 'var(--color-foreground)' }}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p style={{ color: 'var(--color-foreground-muted)' }}>
|
||||
{item.description}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Team Section */}
|
||||
<section
|
||||
className="py-16"
|
||||
style={{ background: 'var(--color-background)' }}
|
||||
>
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2
|
||||
className="text-3xl font-bold mb-4"
|
||||
style={{ color: 'var(--color-foreground)' }}
|
||||
>
|
||||
{pageContent.team.title}
|
||||
</h2>
|
||||
<p
|
||||
className="text-lg"
|
||||
style={{ color: 'var(--color-foreground-muted)' }}
|
||||
>
|
||||
{pageContent.team.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="prose prose-lg mx-auto">
|
||||
{pageContent.team.paragraphs.map((paragraph, index) => (
|
||||
<p
|
||||
key={index}
|
||||
className="leading-relaxed"
|
||||
style={{ color: 'var(--color-foreground-secondary)' }}
|
||||
>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section
|
||||
className="py-16 relative overflow-hidden"
|
||||
style={{ background: 'var(--color-foreground)' }}
|
||||
>
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none opacity-10">
|
||||
<div
|
||||
className="absolute top-10 right-20 w-24 h-24 rounded-full animate-float"
|
||||
style={{ background: 'var(--color-primary-500)' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center relative z-10">
|
||||
<h2
|
||||
className="text-3xl font-bold mb-4"
|
||||
style={{ color: 'var(--color-background)' }}
|
||||
>
|
||||
{pageContent.cta.title}
|
||||
</h2>
|
||||
<p
|
||||
className="text-xl mb-8"
|
||||
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}
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { siteConfig } from '@/config/site'
|
||||
import { content } from '@/content'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
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`,
|
||||
},
|
||||
}
|
||||
|
||||
export default function ServicesPage() {
|
||||
return (
|
||||
<div className="space-y-16 py-8">
|
||||
{/* Hero Section */}
|
||||
<section className="bg-gradient-to-r from-blue-50 to-indigo-50 py-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||
{pageContent.hero.title}
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 leading-relaxed">
|
||||
{pageContent.hero.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 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) => (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">{service.title}</h2>
|
||||
<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>
|
||||
<ul className="space-y-2">
|
||||
{service.features.map((feature, index) => (
|
||||
<li key={index} className="flex items-start">
|
||||
<span className="text-blue-500 mr-3 mt-0.5">✓</span>
|
||||
<span className="text-gray-700">{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/kapcsolat"
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium transition-colors"
|
||||
>
|
||||
{service.ctaText}
|
||||
<svg className="w-4 h-4 ml-2" 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>
|
||||
</section>
|
||||
|
||||
{/* Detailed Services */}
|
||||
<section className="bg-gray-50 py-16">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
{pageContent.details.title}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600">
|
||||
{pageContent.details.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-12">
|
||||
{pageContent.details.services.map((service) => (
|
||||
<div key={service.title} className="bg-white rounded-xl p-8 shadow-sm">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<span className="text-xl">{service.icon}</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-2xl font-bold text-gray-900 mb-4">{service.title}</h3>
|
||||
<div className="prose prose-lg text-gray-700">
|
||||
<p>{service.description}</p>
|
||||
<h4 className="text-lg font-semibold text-gray-900 mt-6 mb-3">{service.specs.title}</h4>
|
||||
<ul className="space-y-1">
|
||||
{service.specs.items.map((item, index) => (
|
||||
<li key={index}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Support Section */}
|
||||
<section className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="bg-blue-50 rounded-xl p-8 text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
{pageContent.support.title}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-700 mb-6">
|
||||
{pageContent.support.subtitle}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-sm">
|
||||
{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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-gray-900 text-white py-16">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||
<h2 className="text-3xl font-bold mb-4">
|
||||
{pageContent.cta.title}
|
||||
</h2>
|
||||
<p className="text-xl text-gray-300 mb-8">
|
||||
{pageContent.cta.subtitle}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a
|
||||
href="/kapcsolat"
|
||||
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}
|
||||
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"
|
||||
>
|
||||
{pageContent.cta.secondaryButton}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user