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>
341 lines
13 KiB
TypeScript
Executable File
341 lines
13 KiB
TypeScript
Executable File
'use client'
|
|
|
|
import { useState } from 'react'
|
|
import { siteConfig } from '@/config/site'
|
|
import { localePath, type Locale } from '@/lib/i18n'
|
|
import type { getContactContent } from '@/lib/payload-content'
|
|
|
|
type ContactViewProps = {
|
|
content: Awaited<ReturnType<typeof getContactContent>>
|
|
contactEmail: string
|
|
footerAddress: string
|
|
webmailHref: string
|
|
locale: Locale
|
|
}
|
|
|
|
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: '',
|
|
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}', contactEmail)}
|
|
</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: gdprLabel + ' *' }}
|
|
/>
|
|
</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:${contactEmail}`}
|
|
className="text-blue-600 hover:text-blue-700"
|
|
>
|
|
{contactEmail}
|
|
</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">{footerAddress}</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
|
|
// 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"
|
|
>
|
|
{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>
|
|
)
|
|
}
|