Merge pull request 'Multi-Language MVP: 4 Locales (DE/EN/UK/RU) mit SSR + UI + Tests' (#79) from feature/multilanguage-mvp into main
All checks were successful
Lint / PHP Syntax Check (push) Successful in 57s
PHPUnit / PHP Unit Tests (push) Successful in 1m8s
Lint / HTML Lint (htmlhint) (push) Successful in 1m39s
Lint / CSS Lint (stylelint) (push) Successful in 1m43s

This commit is contained in:
2026-06-05 23:49:38 +02:00
40 changed files with 4571 additions and 1295 deletions

View File

@@ -1,131 +1,134 @@
name: Deploy Feature Branch to Test
name: Deploy Feature Branch to Test (haus.test.kies-media.de)
on:
push:
branches:
- "feature/**"
workflow_dispatch:
inputs:
ref:
description: "Branch or tag to deploy (default: HEAD)"
required: false
default: ""
jobs:
lint-php:
name: PHP Syntax Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install PHP
run: apt-get update -qq && apt-get install -y -qq php-cli > /dev/null 2>&1
- name: PHP Lint
run: |
errors=0
while IFS= read -r file; do
if ! php -l "$file" > /dev/null 2>&1; then
echo "❌ Syntax error in $file"
php -l "$file"
errors=1
fi
done < <(find . -name "*.php" -not -path "./vendor/*")
if [ "$errors" -eq 1 ]; then
echo "::error::PHP lint check failed"
exit 1
fi
echo "✅ All PHP files pass syntax check"
lint-css:
name: CSS Lint (stylelint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js & stylelint
run: |
apt-get update -qq && apt-get install -y -qq npm nodejs > /dev/null 2>&1
npm install -g stylelint stylelint-config-standard stylelint-prettier > /dev/null 2>&1
- name: CSS Lint
run: |
npx stylelint "**/*.css" --config .stylelintrc.json --allow-empty-input
echo "✅ All CSS files pass lint"
lint-html:
name: HTML Lint (htmlhint)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Node.js & htmlhint
run: |
apt-get update -qq && apt-get install -y -qq npm nodejs > /dev/null 2>&1
npm install -g htmlhint > /dev/null 2>&1
- name: HTML Lint
run: |
npx htmlhint "**/*.html" --config .htmlhintrc
echo "✅ All HTML files pass lint"
deploy:
name: Deploy to Test Environment
runs-on: ubuntu-latest
needs: [lint-php, lint-css, lint-html]
container:
volumes:
- /var/www/test/html:/deploy
concurrency:
group: deploy-test
cancel-in-progress: false
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Show branch info
- name: Setup SSH
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
echo "=== Deploying branch: ${{ gitea.ref_name }} ==="
echo "=== Commit: ${{ gitea.sha }} ==="
echo "=== By: ${{ gitea.actor }} ==="
echo "=== All lint checks passed ✅ ==="
date
if [ -z "$DEPLOY_SSH_KEY" ]; then
echo "⚠️ DEPLOY_SSH_KEY secret not set — skipping deploy step"
echo "skip_deploy=1" >> $GITHUB_ENV
exit 0
fi
mkdir -p ~/.ssh
echo "$DEPLOY_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H 188.245.242.194 >> ~/.ssh/known_hosts 2>/dev/null
echo "✅ SSH key configured"
- name: Deploy to test environment
- name: Verify SSH connectivity
if: env.skip_deploy != '1'
run: |
echo "Syncing files to test environment..."
apt-get update -qq && apt-get install -y -qq rsync > /dev/null 2>&1 || true
ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519 \
haustest@188.245.242.194 "echo 'SSH-OK as:' \$(whoami) 'on' \$(hostname)"
rsync -av --delete \
- name: Backup current test deployment
if: env.skip_deploy != '1'
run: |
ssh -i ~/.ssh/id_ed25519 haustest@188.245.242.194 \
"cd /home/haustest/htdocs && \
tar czf /home/haustest/backup-pre-deploy-\$(date +%Y%m%d-%H%M%S).tar.gz \
haus.test.kies-media.de && \
ls -lh /home/haustest/backup-pre-deploy-*.tar.gz | tail -1"
- name: Rsync to test environment
if: env.skip_deploy != '1'
run: |
rsync -avz --delete \
--exclude='.git' \
--exclude='node_modules' \
--exclude='tests' \
--exclude='docs' \
--exclude='.gitea' \
--exclude='.husky' \
--exclude='.prettierrc' \
--exclude='.prettierignore' \
--exclude='.stylelintrc.json' \
--exclude='.htmlhintrc' \
--exclude='.gitignore' \
--exclude='.dockerignore' \
--exclude='.continue' \
--exclude='.husky' \
--exclude='Dockerfile' \
--exclude='.dockerignore' \
--exclude='nginx.conf' \
--exclude='eslint.config.js' \
--exclude='package.json' \
--exclude='package-lock.json' \
--exclude='docs/' \
--exclude='phpunit.xml' \
--exclude='scripts' \
--exclude='AGENTS.md' \
--exclude='README.md' \
--exclude='scripts/' \
./ /deploy/
--exclude='CLAUDE.md' \
--exclude='*.md' \
--exclude='.htmlhintrc' \
--exclude='.prettierrc' \
--exclude='.prettierignore' \
--exclude='.stylelintrc.json' \
--exclude='.editorconfig' \
--exclude='.well-known' \
-e "ssh -i ~/.ssh/id_ed25519" \
./ haustest@188.245.242.194:/home/haustest/htdocs/haus.test.kies-media.de/
echo "✅ Deployment complete!"
- name: Set permissions
- name: Smoke test
if: env.skip_deploy != '1'
run: |
chown -R 33:33 /deploy/ 2>/dev/null || true
chmod -R 755 /deploy/ 2>/dev/null || true
echo "✅ Permissions set"
sleep 2
echo "--- HTTP status codes ---"
for path in "/" "/impressum" "/datenschutz"; do
code=$(curl -s -o /dev/null -w "%{http_code}" \
-H "Cache-Control: no-cache" \
"https://haus.test.kies-media.de${path}")
echo " $path → HTTP $code"
if [ "$code" != "200" ]; then
echo "❌ Smoke test failed for $path"
exit 1
fi
done
echo ""
echo "--- Locale switcher present? ---"
if curl -sL "https://haus.test.kies-media.de/" | grep -q "class=\"locale-switcher\""; then
echo " ✅ Locale switcher rendered"
else
echo " ❌ Locale switcher MISSING"
exit 1
fi
echo ""
echo "--- All 4 locales serving? ---"
for loc in de en uk ru; do
lang=$(curl -sL -H "Cache-Control: no-cache" \
-b "locale=$loc" \
"https://haus.test.kies-media.de/" \
| grep -oE '<html lang="[a-z]+"' | head -1)
echo " locale=$loc → $lang"
done
echo ""
echo "🎉 Test deployment verified: https://haus.test.kies-media.de"
- name: Deployment summary
if: always()
run: |
echo "=========================================="
echo " 🚀 Deployment Summary"
echo "=========================================="
echo " Branch: ${{ gitea.ref_name }}"
echo " Commit: ${{ gitea.sha }}"
echo " Target: http://178.104.150.0:6427/"
echo " Lint: ✅ All checks passed"
echo " Time: $(date)"
echo "=========================================="
echo "### 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Target:** https://haus.test.kies-media.de" >> $GITHUB_STEP_SUMMARY
echo "- **Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo "- **Server:** haustest@188.245.242.194" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Review URL:** https://haus.test.kies-media.de" >> $GITHUB_STEP_SUMMARY

3
.gitignore vendored
View File

@@ -1,8 +1,11 @@
*.ps1
*.py
!/tests/E2E/*.py
/node_modules/
package-lock.json
.continue/
.playwright-mcp/
vendor/
.phpunit.cache/
build/
.phpunit.coverage.cache/

View File

@@ -4,8 +4,14 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Core\I18n;
use App\Core\Locale;
use App\Core\View;
/**
* Base controller — injects i18n globals (locale, t() helper, locale switcher)
* into every render() call so views can use `$t('key')` and `$locale` directly.
*/
abstract class Controller
{
protected View $view;
@@ -15,9 +21,31 @@ abstract class Controller
$this->view = new View();
}
protected function render(string $view, array $data = [], string $layout = 'main'): void
/**
* Render a view inside a layout.
*
* @param array<string,mixed> $data
* @param string|null $forceLocale If set, overrides the locale resolved from
* cookie/Accept-Language for this render. Used by legal pages (Impressum,
* Datenschutz) that must be served in German only by German law.
*/
protected function render(string $view, array $data = [], string $layout = 'main', ?string $forceLocale = null): void
{
foreach ($data as $key => $value) {
$locale = $forceLocale ?? LocaleController::current();
$i18n = static fn (string $key, array $params = []): string => I18n::t($key, $params, $locale);
$globals = [
'locale' => $locale,
't' => $i18n,
'locale_switcher' => static function (string $currentPath) use ($locale): string {
$switcher = new LocaleSwitcher($locale, $currentPath);
return $switcher->render();
},
];
$merged = array_merge($globals, $data);
foreach ($merged as $key => $value) {
$this->view->assign($key, $value);
}
$this->view->render($view, $layout);

View File

@@ -4,15 +4,25 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Core\I18n;
use App\Core\Locale;
class DatenschutzController extends Controller
{
public function index(): void
{
// Legal pages (Datenschutzerklärung) must be served in German only by GDPR / German law.
// Force German locale for render() so <html lang="de"> + German meta are emitted
// regardless of cookie/Accept-Language.
$this->render('datenschutz/index', [
'pageTitle' => 'Datenschutzerklärung Haus Schleusingen',
'pageDescription' => 'Datenschutzerklärung der Website haus-schleusingen.de',
'pageTitle' => I18n::t('legal.privacy_h1', [], 'de') . ' ' . I18n::t('site.title', [], 'de'),
'pageDescription' => I18n::t('legal.privacy_h1', [], 'de') . ' ' . I18n::t('site.title', [], 'de'),
'robots' => 'noindex',
'canonical' => 'https://haus-schleusingen.de/datenschutz',
]);
'canonical' => I18n::t('site.canonical_base', [], 'de') . '/datenschutz',
'ogLocale' => Locale::toOgLocale('de'),
'ogUrl' => I18n::t('site.canonical_base', [], 'de') . '/datenschutz',
'ogTitle' => I18n::t('legal.privacy_h1', [], 'de'),
'ogDescription' => I18n::t('legal.privacy_h1', [], 'de'),
], 'main', 'de');
}
}

View File

@@ -4,159 +4,163 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Core\I18n;
use App\Core\Locale;
class HomeController extends Controller
{
/** Map of interest option translation key → internal identifier. */
private const INTEREST_KEYS = [
'visit' => 'form.interest.visit',
'info' => 'form.interest.info',
'apply' => 'form.interest.apply',
];
public function index(): void
{
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
// --- Helper functions ---
$normalizeContactValue = function (string $value): string {
return trim($value);
};
$locale = LocaleController::current();
$escapeContactValue = function (string $value): string {
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
};
$escapeContactValue = static fn(string $value): string
=> htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
$containsHeaderInjection = function (string $value): bool {
$containsHeaderInjection = static function (string $value): bool {
return (bool) preg_match('/[\r\n]/', $value);
};
// --- Form processing ---
$formErrors = [];
$formSuccess = false;
if (!empty($_SESSION['form_success'])) {
$formSuccess = true;
unset($_SESSION['form_success']);
}
if (!empty($_SESSION['form_errors'])) {
$formErrors = $_SESSION['form_errors'];
unset($_SESSION['form_errors']);
}
if (!empty($_SESSION['form_data'])) {
$formData = $_SESSION['form_data'];
unset($_SESSION['form_data']);
// ── Pull flashed state ────────────────────────────────────────
$formSuccess = !empty($_SESSION['form_success']);
$formErrors = $_SESSION['form_errors'] ?? [];
$formFieldErrors = $_SESSION['form_field_errors'] ?? [];
$formData = $_SESSION['form_data'] ?? null;
unset(
$_SESSION['form_success'],
$_SESSION['form_errors'],
$_SESSION['form_field_errors'],
$_SESSION['form_data'],
);
if ($formSuccess) {
$formData = self::emptyFormData();
$formFieldErrors = [];
} elseif (!is_array($formData)) {
$formData = self::emptyFormData();
$formFieldErrors = is_array($formFieldErrors) ? $formFieldErrors : [];
} else {
$formData = ['fname' => '', 'lname' => '', 'email' => '', 'phone' => '', 'interest' => 'Besichtigung anfragen', 'message' => ''];
$formFieldErrors = is_array($formFieldErrors) ? $formFieldErrors : [];
}
// CSRF-Token generieren (nach Session-Start)
// ── CSRF token ────────────────────────────────────────────────
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// CSRF-Token validieren
$csrfToken = $_POST['csrf_token'] ?? '';
// ── Form processing ───────────────────────────────────────────
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
$csrfToken = (string) ($_POST['csrf_token'] ?? '');
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfToken)) {
header('Location: /#form-result');
$_SESSION['form_errors'] = ['Sicherheitsüberprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.'];
$_SESSION['form_errors'] = ['form.error.csrf'];
header('Location: /#kontakt');
exit;
}
$formData['fname'] = $normalizeContactValue((string) ($_POST['fname'] ?? ''));
$formData['lname'] = $normalizeContactValue((string) ($_POST['lname'] ?? ''));
$formData['email'] = $normalizeContactValue((string) ($_POST['email'] ?? ''));
$formData['phone'] = $normalizeContactValue((string) ($_POST['phone'] ?? ''));
$formData['interest'] = $normalizeContactValue((string) ($_POST['interest'] ?? ''));
$formData['message'] = $normalizeContactValue((string) ($_POST['message'] ?? ''));
$formData['fname'] = trim((string) ($_POST['fname'] ?? ''));
$formData['lname'] = trim((string) ($_POST['lname'] ?? ''));
$formData['email'] = trim((string) ($_POST['email'] ?? ''));
$formData['phone'] = trim((string) ($_POST['phone'] ?? ''));
$formData['interest'] = trim((string) ($_POST['interest'] ?? 'visit'));
$formData['message'] = trim((string) ($_POST['message'] ?? ''));
$honeypot = $normalizeContactValue((string) ($_POST['website'] ?? ''));
// Honeypot: bots succeed silently.
$honeypot = trim((string) ($_POST['website'] ?? ''));
if ($honeypot !== '') {
header('Location: /#form-result');
$_SESSION['form_success'] = true;
header('Location: /#kontakt');
exit;
} else {
}
// Per-field errors enable aria-invalid + aria-describedby.
$formFieldErrors = [];
if ($formData['fname'] === '') {
$formErrors[] = 'Bitte geben Sie Ihren Vornamen an.';
$formFieldErrors['fname'][] = 'form.error.fname_required';
}
if ($formData['lname'] === '') {
$formErrors[] = 'Bitte geben Sie Ihren Nachnamen an.';
$formFieldErrors['lname'][] = 'form.error.lname_required';
}
if ($formData['email'] === '' || !filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
$formErrors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
$formFieldErrors['email'][] = 'form.error.email_invalid';
}
if ($formData['message'] === '') {
$formErrors[] = 'Bitte geben Sie eine Nachricht ein.';
$formFieldErrors['message'][] = 'form.error.message_required';
}
if ($containsHeaderInjection($formData['email']) || $containsHeaderInjection($formData['fname'] . ' ' . $formData['lname'])) {
$formErrors[] = 'Ungültige Zeichen in den Eingabefeldern.';
$formErrors[] = 'form.error.header_injection';
}
$formTime = isset($_POST['form_time']) ? (int) $_POST['form_time'] : 0;
if ($formTime > 0 && (time() - $formTime) < 3) {
$formErrors[] = 'Das Formular wurde zu schnell abgeschickt. Bitte versuchen Sie es erneut.';
$formErrors[] = 'form.error.too_fast';
}
$lastSubmit = $_SESSION['last_contact_submit'] ?? 0;
if ($lastSubmit && (time() - $lastSubmit) < 60) {
$formErrors[] = 'Bitte warten Sie einen Moment vor der nächsten Anfrage.';
$formErrors[] = 'form.error.rate_limit';
}
if (empty($formErrors)) {
if (empty($formErrors) && empty($formFieldErrors)) {
$interestKey = self::INTEREST_KEYS[$formData['interest']] ?? 'form.interest.visit';
$interestLabel = I18n::t($interestKey, [], $locale);
$to = 'mki@kies-media.de';
$subject = 'Kontaktanfrage: ' . $formData['interest'];
$body = "Von: {$formData['fname']} {$formData['lname']}\n"
. "E-Mail: {$formData['email']}\n";
if ($formData['phone'] !== '') {
$body .= "Telefon: {$formData['phone']}\n";
}
$body .= "Anliegen: {$formData['interest']}\n\n"
. $formData['message'];
$subject = 'Kontaktanfrage: ' . $interestLabel;
$body = sprintf(
"Von: %s %s\nE-Mail: %s\n%sAnliegen: %s\n\n%s",
$formData['fname'],
$formData['lname'],
$formData['email'],
$formData['phone'] !== '' ? "Telefon: {$formData['phone']}\n" : '',
$interestLabel,
$formData['message']
);
$headers = "From: {$formData['email']}\r\n";
$headers .= "Reply-To: {$formData['email']}\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
$headers .= "X-Mailer: PHP/" . phpversion();
$mailSent = mail($to, $subject, $body, $headers);
if ($mailSent) {
if (mail($to, $subject, $body, $headers)) {
$_SESSION['last_contact_submit'] = time();
header('Location: /#form-result');
$_SESSION['form_success'] = true;
header('Location: /#kontakt');
exit;
} else {
$formErrors[] = 'Leider konnte die E-Mail nicht gesendet werden. Bitte versuchen Sie es später erneut oder schreiben Sie uns direkt an mki@kies-media.de.';
}
}
}
if (!empty($formErrors)) {
header('Location: /#form-result');
$_SESSION['form_errors'] = $formErrors;
$_SESSION['form_data'] = $formData;
exit;
}
}
$this->render('home/index', [
'formSuccess' => $formSuccess,
'formErrors' => $formErrors,
'formData' => $formData,
'escapeContactValue' => $escapeContactValue,
'pageTitle' => 'Einfamilienhaus mieten Schleusingen | 227 m², 6 Zimmer | 1.300 € Kaltmiete',
'pageDescription' => 'Einfamilienhaus zur Langzeitmiete in Schleusingen: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €. Bahnhofstraße 10, 98553 Schleusingen. Ab sofort verfügbar.',
'canonical' => 'https://haus-schleusingen.de/',
'openGraph' => [
'ogTitle' => 'Einfamilienhaus zur Miete in Schleusingen 227 m², 6 Zimmer',
'ogDescription' => 'Großzügiges Einfamilienhaus zur Langzeitmiete: 227 m², 6 Zimmer, 3 Etagen + Dachterrasse. Kaltmiete 1.300 €. Ab sofort verfügbar in Schleusingen.',
'ogImage' => 'https://haus-schleusingen.de/bilder/Außenansicht-2.png',
'ogUrl' => 'https://haus-schleusingen.de/',
],
'structuredData' => json_encode([
$formErrors[] = 'form.error.send_failed';
}
$_SESSION['form_errors'] = $formErrors;
$_SESSION['form_field_errors'] = $formFieldErrors;
$_SESSION['form_data'] = $formData;
header('Location: /#kontakt');
exit;
}
// ── Structured data (JSON-LD) — localized ────────────────────
$structuredData = json_encode([
'@context' => 'https://schema.org',
'@type' => 'RealEstateListing',
'name' => 'Einfamilienhaus zur Miete in Schleusingen',
'description' => 'Großzügiges Einfamilienhaus zur Langzeitmiete: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €.',
'url' => 'https://haus-schleusingen.de/',
'image' => 'https://haus-schleusingen.de/bilder/Außenansicht-2.png',
'name' => I18n::t('structured.listing_name', [], $locale),
'description'=> I18n::t('structured.listing_description', [], $locale),
'url' => I18n::t('site.canonical_base', [], $locale) . '/',
'image' => I18n::t('site.canonical_base', [], $locale) . '/bilder/Außenansicht-2.png',
'datePosted' => '2026-05-14',
'address' => [
'@type' => 'PostalAddress',
'streetAddress' => 'Bahnhofstraße 10',
'addressLocality' => 'Schleusingen',
'streetAddress' => I18n::t('address.street', [], $locale),
'addressLocality' => I18n::t('address.city', [], $locale),
'postalCode' => '98553',
'addressCountry' => 'DE',
],
@@ -169,7 +173,7 @@ class HomeController extends Controller
'price' => '1300',
'priceCurrency' => 'EUR',
'unitCode' => 'MON',
'description' => 'Kaltmiete pro Monat',
'description' => I18n::t('structured.price_description', [], $locale),
],
],
'floorSize' => [
@@ -181,7 +185,31 @@ class HomeController extends Controller
'@type' => 'QuantitativeValue',
'value' => '6',
],
]),
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$this->render('home/index', [
'formSuccess' => $formSuccess,
'formErrors' => $formErrors,
'formFieldErrors' => $formFieldErrors,
'formData' => $formData,
'interestKeys' => self::INTEREST_KEYS,
'escapeContactValue' => $escapeContactValue,
'structuredData' => $structuredData,
]);
}
/**
* @return array{fname: string, lname: string, email: string, phone: string, interest: string, message: string}
*/
private static function emptyFormData(): array
{
return [
'fname' => '',
'lname' => '',
'email' => '',
'phone' => '',
'interest' => 'visit',
'message' => '',
];
}
}

View File

@@ -4,15 +4,25 @@ declare(strict_types=1);
namespace App\Controllers;
use App\Core\I18n;
use App\Core\Locale;
class ImpressumController extends Controller
{
public function index(): void
{
// Legal pages (Impressum) must be served in German only by German law (TMG §5).
// Force German locale for render() so <html lang="de"> + German meta are emitted
// regardless of cookie/Accept-Language.
$this->render('impressum/index', [
'pageTitle' => 'Impressum Haus Schleusingen',
'pageDescription' => 'Impressum der Website haus-schleusingen.de',
'pageTitle' => I18n::t('legal.imprint_h1', [], 'de') . ' ' . I18n::t('site.title', [], 'de'),
'pageDescription' => I18n::t('legal.imprint_h1', [], 'de') . ' ' . I18n::t('site.title', [], 'de'),
'robots' => 'noindex',
'canonical' => 'https://haus-schleusingen.de/impressum',
]);
'canonical' => I18n::t('site.canonical_base', [], 'de') . '/impressum',
'ogLocale' => Locale::toOgLocale('de'),
'ogUrl' => I18n::t('site.canonical_base', [], 'de') . '/impressum',
'ogTitle' => I18n::t('legal.imprint_h1', [], 'de'),
'ogDescription' => I18n::t('legal.imprint_h1', [], 'de'),
], 'main', 'de');
}
}

View File

@@ -0,0 +1,145 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\Locale;
/**
* Handles locale switching and exposes the current locale to the front
* controller / View layer.
*
* Entry point: GET /locale?set=xx&return=/some/path
* - `set` (required) target locale, must be in {@see Locale::SUPPORTED}
* - `return` (optional) explicit return URL; falls back to `Referer` header,
* then `/` if neither is present or is a same-origin path.
*
* On success: 302 to return URL, sets a 1-year `locale` cookie.
* On failure: 302 to `/`, no cookie set.
*
* The class is split into a pure {@see buildResponse()} for unit testing
* and a side-effectful {@see switch()} for production.
*/
class LocaleController extends Controller
{
public const COOKIE_NAME = 'locale';
/**
* Public entry point — invoked by the front controller.
* Reads $_GET, sends headers, terminates the request.
*/
public function switch(): void
{
$locale = isset($_GET['set']) && is_string($_GET['set']) ? $_GET['set'] : null;
$return = isset($_GET['return']) && is_string($_GET['return']) ? $_GET['return'] : null;
$referer = $_SERVER['HTTP_REFERER'] ?? null;
$isHttps = (($_SERVER['HTTPS'] ?? '') !== '' && $_SERVER['HTTPS'] !== 'off')
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')
|| (($_SERVER['SERVER_PORT'] ?? '') === '443');
$resp = self::buildResponse($locale, $return, $referer, $isHttps);
if ($resp['set_cookie']) {
$params = [
'expires' => $resp['cookie_expires'],
'path' => '/',
'secure' => $resp['cookie_secure'],
'httponly' => false, // read by JS-free client (we keep it readable for SSR)
'samesite' => 'Lax',
];
setcookie(self::COOKIE_NAME, $resp['cookie_value'], $params);
}
header('Location: ' . $resp['redirect'], true, $resp['status']);
exit;
}
/**
* Pure response builder — testable without headers/exit.
*
* @return array{
* status: int,
* redirect: string,
* set_cookie: bool,
* cookie_value: string,
* cookie_expires: int,
* cookie_secure: bool
* }
*/
public static function buildResponse(
?string $locale,
?string $explicitReturn,
?string $referer,
bool $isHttps,
): array {
$valid = is_string($locale) && Locale::isSupported($locale);
$redirect = self::safeRedirect($explicitReturn, $referer);
if (!$valid) {
return [
'status' => 302,
'redirect' => $redirect,
'set_cookie' => false,
'cookie_value' => '',
'cookie_expires' => 0,
'cookie_secure' => $isHttps,
];
}
return [
'status' => 302,
'redirect' => $redirect,
'set_cookie' => true,
'cookie_value' => $locale,
'cookie_expires' => time() + 60 * 60 * 24 * 365, // 1 year
'cookie_secure' => $isHttps,
];
}
/**
* Compute the current locale from $_GET, $_COOKIE and Accept-Language.
* Convenience for the front controller / View layer.
*/
public static function current(): string
{
return Locale::resolve(
isset($_GET['lang']) && is_string($_GET['lang']) ? $_GET['lang'] : null,
$_COOKIE[self::COOKIE_NAME] ?? null,
$_SERVER['HTTP_ACCEPT_LANGUAGE'] ?? null,
);
}
/**
* Sanitize the return URL — same-origin path-only. Anything with a
* scheme, host, or `//` prefix is rejected and replaced with `/`.
*/
private static function safeRedirect(?string $explicit, ?string $referer): string
{
$candidate = $explicit ?: $referer;
if (!is_string($candidate) || $candidate === '') {
return '/';
}
// Reject absolute URLs and protocol-relative BEFORE backslash fixup,
// so a backslash in the input doesn't smuggle a `//` past us.
if (preg_match('#^(https?:)?//#i', $candidate)) {
return '/';
}
// Normalize backslashes (some browsers treat \ as /)
$candidate = str_replace('\\', '/', $candidate);
// After normalization, re-check for `//` (could be a backslash trick).
if (preg_match('#^//#', $candidate)) {
return '/';
}
if ($candidate[0] !== '/') {
return '/';
}
return $candidate;
}
}

View File

@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\I18n;
use App\Core\Locale;
/**
* Renders the language switcher widget. Pure HTML generation — no
* side effects, no header writing.
*
* Single <details>-based dropdown shown at every viewport. The
* trigger is a flag-sized button (24×16 SVG + tiny caret) that
* opens to a menu of all 4 supported locales.
*
* Each menu option gets:
* - an inline 24×16 SVG flag,
* - `hreflang` and `lang` for SEO and screen readers,
* - `aria-current="true"` on the active option.
*
* The active option is rendered as a <span> (not a link) so it
* cannot be reactivated. The trigger and every menu option are
* ≥44px touch targets via CSS.
*/
final class LocaleSwitcher
{
public function __construct(
private readonly string $currentLocale,
private readonly string $currentPath,
) {
}
public function render(): string
{
$path = $this->sanitisePath($this->currentPath);
$ariaLabel = htmlspecialchars(
I18n::t('locale.switcher.aria', [], $this->currentLocale),
ENT_QUOTES,
'UTF-8',
);
$currentName = htmlspecialchars(
I18n::t('locale.' . $this->currentLocale, [], $this->currentLocale),
ENT_QUOTES,
'UTF-8',
);
$currentCode = htmlspecialchars($this->currentLocale, ENT_QUOTES, 'UTF-8');
$currentFlag = self::flagImg($this->currentLocale);
$html = '<details class="locale-switcher">';
$html .= '<summary class="locale-switcher__trigger"'
. ' aria-label="' . $ariaLabel . '"'
. ' title="' . $currentName . '"'
. '>';
$html .= '<span class="locale-switcher__current" lang="' . $currentCode . '">';
$html .= $currentFlag;
$html .= '</span>';
$html .= '<span class="locale-switcher__caret" aria-hidden="true">▾</span>';
$html .= '</summary>';
$html .= '<ul class="locale-switcher__menu" role="list">';
foreach (Locale::SUPPORTED as $code) {
$isCurrent = $code === $this->currentLocale;
$name = htmlspecialchars(
I18n::t('locale.' . $code, [], $this->currentLocale),
ENT_QUOTES,
'UTF-8',
);
$codeAttr = htmlspecialchars($code, ENT_QUOTES, 'UTF-8');
$flag = self::flagImg($code);
$html .= '<li>';
if ($isCurrent) {
$html .= '<span class="locale-switcher__option is-current"'
. ' aria-current="true"'
. ' lang="' . $codeAttr . '">'
. $flag
. '<span class="locale-switcher__label">' . $name . '</span>'
. '</span>';
} else {
$url = '/locale?set=' . rawurlencode($code) . '&amp;return=' . rawurlencode($path);
$html .= '<a class="locale-switcher__option"'
. ' href="' . $url . '"'
. ' hreflang="' . $codeAttr . '"'
. ' lang="' . $codeAttr . '"'
. ' rel="alternate"'
. '>'
. $flag
. '<span class="locale-switcher__label">' . $name . '</span>'
. '</a>';
}
$html .= '</li>';
}
$html .= '</ul>';
$html .= '</details>';
return $html;
}
/**
* Country flag for the given locale. Renders a 24×18 <img>
* pointing at the official flag-icons SVG asset shipped under
* public/img/flags/. 4:3 aspect (de/gb/ua/ru), crisp at any DPI,
* no external CDN dependency.
*
* Decorative: `alt=""` (the visible locale-switcher label and
* the <a>'s `hreflang`/`lang` carry the accessible name).
*/
public static function flagImg(string $locale): string
{
$src = self::flagSource($locale);
// 32×24 = ~4:3, large enough that the flag is the visual
// anchor of the option. No loading="lazy" — these are 4
// small SVGs that must be ready the moment the <details>
// opens (lazy would cause a flash of empty boxes).
return '<img class="flag" src="' . $src . '" alt="" width="32" height="24">';
}
/**
* Map our locale codes to flag-icons file names. Locale "en"
* is en-GB per ADR-002, so the asset is "gb.svg". Anything we
* do not know falls back to a transparent 1×1 gif so the layout
* stays intact and the alt text (from the surrounding <a>) is
* the only signal.
*/
private static function flagSource(string $locale): string
{
$file = match ($locale) {
'de' => 'de',
'en' => 'gb',
'uk' => 'ua',
'ru' => 'ru',
default => null,
};
if ($file === null) {
return 'data:image/gif;base64,R0lGODlhAQABAAAAACwAAAAAAQABAAACAkQBADs=';
}
return '/img/flags/' . $file . '.svg';
}
/**
* Make sure the path is safe to embed as a query string value and
* a redirect target. Drops query/fragment, keeps only the path.
*/
private function sanitisePath(string $path): string
{
$path = parse_url($path, PHP_URL_PATH) ?: '/';
if ($path === '' || $path[0] !== '/') {
return '/';
}
return $path;
}
}

132
app/Core/I18n.php Normal file
View File

@@ -0,0 +1,132 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Translation loader + t() helper.
*
* Loads flat `key => 'text'` arrays from `app/Locales/{locale}.php` once per
* request per locale, caches in static memory. Supports {placeholder}
* interpolation.
*
* Fallback chain: current locale → 'de' → key string itself (with optional
* missing-key indicator in dev).
*
* Stateless on the instance — `t()` is a static method so views can call
* it without a container.
*/
final class I18n
{
/** @var array<string, array<string,string>> locale => [key => text] */
private static array $cache = [];
/** @var string|null Path to the Locales directory (overridable for tests) */
private static ?string $localesPath = null;
/**
* Translate a key in the current locale, with {placeholder} interpolation.
*
* @param string $key Dotted key, e.g. 'nav.gallery'
* @param array<string,string> $params Placeholders: ['name' => 'Martin']
* @param string|null $locale Override locale (defaults to current)
*/
public static function t(string $key, array $params = [], ?string $locale = null): string
{
$locale ??= Locale::DEFAULT;
// Unsupported locale = likely a developer bug; surface the key
// rather than silently falling back to DE.
if (!Locale::isSupported($locale)) {
return self::interpolate($key, $params);
}
$text = self::lookup($key, $locale);
if ($text === null && $locale !== Locale::DEFAULT) {
$text = self::lookup($key, Locale::DEFAULT);
}
$text ??= $key;
return self::interpolate($text, $params);
}
/**
* Plural-aware translation. MVP: no ICU — we just append `{n}` to the
* key so the caller provides singular and plural variants.
*
* @param array<string,string> $params
*/
public static function tn(string $keySingular, string $keyPlural, int $n, array $params = [], ?string $locale = null): string
{
$key = $n === 1 ? $keySingular : $keyPlural;
$params = array_merge($params, ['n' => (string) $n]);
return self::t($key, $params, $locale);
}
/**
* Check whether a key exists in the given locale (or the default).
* Useful for tests and conditional UI logic.
*/
public static function has(string $key, ?string $locale = null): bool
{
$locale ??= Locale::DEFAULT;
return self::lookup($key, $locale) !== null
|| ($locale !== Locale::DEFAULT && self::lookup($key, Locale::DEFAULT) !== null);
}
/**
* Reset the in-memory cache. Test-only utility.
*/
public static function flushCache(): void
{
self::$cache = [];
}
/**
* Override the Locales directory. Test-only utility.
*/
public static function setLocalesPath(string $path): void
{
self::$localesPath = $path;
}
/**
* Look up a key in a specific locale's array.
*/
private static function interpolate(string $text, array $params): string
{
if ($params === []) {
return $text;
}
$search = array_map(static fn (string $k): string => '{' . $k . '}', array_keys($params));
$replace = array_values($params);
return str_replace($search, $replace, $text);
}
private static function lookup(string $key, string $locale): ?string
{
if (!Locale::isSupported($locale)) {
return null;
}
if (!isset(self::$cache[$locale])) {
$file = self::localesPath() . '/' . $locale . '.php';
if (!is_file($file)) {
self::$cache[$locale] = [];
return null;
}
$data = require $file;
self::$cache[$locale] = is_array($data) ? $data : [];
}
return self::$cache[$locale][$key] ?? null;
}
private static function localesPath(): string
{
if (self::$localesPath !== null) {
return self::$localesPath;
}
return dirname(__DIR__) . '/Locales';
}
}

162
app/Core/Locale.php Normal file
View File

@@ -0,0 +1,162 @@
<?php
declare(strict_types=1);
namespace App\Core;
/**
* Locale resolution: query-param → cookie → Accept-Language → fallback 'de'.
*
* Immutable. No globals on the instance — all input is passed explicitly so
* the class is trivial to unit-test.
*
* Supported locales: 'de' (default), 'en', 'uk', 'ru'.
*
* The class is a thin value object; resolution is a static method so it
* can be called from anywhere (controllers, views, tests) without wiring.
*/
final class Locale
{
public const DEFAULT = 'de';
/** @var list<string> ISO 639-1 codes, de is the source of truth */
public const SUPPORTED = ['de', 'en', 'uk', 'ru'];
/**
* Resolve a locale from request signals.
*
* Priority: explicit query/cookie > Accept-Language header > default.
*
* @param string|null $queryParam Value of ?lang= (raw, unvalidated)
* @param string|null $cookieValue Value of the 'locale' cookie (raw)
* @param string|null $acceptLanguage Raw Accept-Language header
*/
public static function resolve(
?string $queryParam = null,
?string $cookieValue = null,
?string $acceptLanguage = null,
): string {
// 1. Query param wins (one-shot, used by LocaleController to set cookie)
if (is_string($queryParam) && self::isSupported($queryParam)) {
return $queryParam;
}
// 2. Cookie next
if (is_string($cookieValue) && self::isSupported($cookieValue)) {
return $cookieValue;
}
// 3. Accept-Language header
if (is_string($acceptLanguage) && $acceptLanguage !== '') {
$parsed = self::parseAcceptLanguage($acceptLanguage);
foreach ($parsed as $tag) {
if (self::isSupported($tag)) {
return $tag;
}
}
}
// 4. Fallback
return self::DEFAULT;
}
/**
* Normalize an Accept-Language header into a list of ISO 639-1 codes
* sorted by q-value (highest first), with q=0 entries dropped.
*
* Handles wildcards ("*") and BCP-47 subtags ("en-US" → "en",
* "uk-UA" → "uk"). Entries with the same q-value keep header order
* (stable).
*
* @return list<string>
*/
public static function parseAcceptLanguage(string $header): array
{
$header = trim($header);
if ($header === '') {
return [];
}
$entries = [];
foreach (explode(',', $header) as $i => $part) {
$parts = explode(';', trim($part));
$tag = trim($parts[0]);
$q = 1.0;
for ($j = 1; $j < count($parts); $j++) {
if (preg_match('/^q\s*=\s*([0-9.]+)$/i', trim($parts[$j]), $m)) {
$q = (float) $m[1];
}
}
if ($q <= 0.0) {
continue;
}
// Strip BCP-47 region: "en-US" → "en", "uk-UA" → "uk"
$primary = strtolower(explode('-', $tag)[0]);
if ($primary === '*' || $primary === '') {
continue;
}
// Sort key: -q (descending) and original position (ascending)
$entries[] = [
'tag' => $primary,
'q' => $q,
'pos' => $i,
];
}
usort($entries, static function (array $a, array $b): int {
if ($a['q'] !== $b['q']) {
return $b['q'] <=> $a['q'];
}
return $a['pos'] <=> $b['pos'];
});
return array_values(array_map(static fn (array $e): string => $e['tag'], $entries));
}
/**
* Check whether a code is in {@see self::SUPPORTED}.
*/
public static function isSupported(string $code): bool
{
return in_array($code, self::SUPPORTED, true);
}
/**
* Map ISO 639-1 → BCP-47 og:locale format.
* Used by View layout for <meta property="og:locale">.
*/
public static function toOgLocale(string $code): string
{
return match ($code) {
'de' => 'de_DE',
'en' => 'en_GB', // UK English by user requirement
'uk' => 'uk_UA',
'ru' => 'ru_RU',
default => 'de_DE',
};
}
/**
* Build the full hreflang alternate list for the current page, given its
* canonical path. Returns an array of ['locale' => 'hreflang', 'href' => url].
*
* @return list<array{locale:string, hreflang:string, href:string}>
*/
public static function hreflangAlternates(string $canonicalPath, string $baseUrl = 'https://haus-schleusingen.de'): array
{
$out = [];
foreach (self::SUPPORTED as $code) {
$hreflang = $code === 'en' ? 'en-GB' : ($code === 'uk' ? 'uk' : $code);
$out[] = [
'locale' => $code,
'hreflang' => $hreflang,
'href' => $baseUrl . $canonicalPath . '?lang=' . $code,
];
}
return $out;
}
}

229
app/Locales/de.php Normal file
View File

@@ -0,0 +1,229 @@
<?php
declare(strict_types=1);
/**
* German (DE) translations — source of truth.
*
* Keys are dot-separated, e.g. 'nav.cta' => '...'.
* Use {placeholders} for runtime interpolation: t('greeting', ['name' => 'Anna']).
*
* @see \App\Core\I18n::t()
*/
return [
// ─── Site meta ───────────────────────────────────────────────────────
'site.name' => 'Haus Schleusingen',
'site.title' => 'Einfamilienhaus mieten Schleusingen | 227 m², 6 Zimmer | 1.300 € Kaltmiete',
'site.description' => 'Einfamilienhaus zur Langzeitmiete in Schleusingen: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €. Bahnhofstraße 10, 98553 Schleusingen. Ab sofort verfügbar.',
'site.og_locale' => 'de_DE',
'site.canonical_base' => 'https://haus-schleusingen.de',
// ─── Navigation ──────────────────────────────────────────────────────
'nav.skip' => 'Zum Inhalt springen',
'nav.main' => 'Hauptnavigation',
'nav.toggle' => 'Navigation öffnen',
'nav.gallery' => 'Galerie',
'nav.layout' => 'Grundriss',
'nav.rent' => 'Miete',
'nav.location' => 'Lage',
'nav.cta' => 'Jetzt anfragen',
// ─── Hero ────────────────────────────────────────────────────────────
'hero.tag' => 'Zur Langzeitmiete · Ab sofort verfügbar',
'hero.h1.line1' => 'Großzügiges',
'hero.h1.line2' => 'Einfamilienhaus',
'hero.h1.line3' => 'in Schleusingen',
'hero.address' => 'Schleusinger Bahnhofstraße 10',
'hero.area' => '227 m² Wohnfläche',
'hero.rooms' => '6 Zimmer',
'hero.floors' => '3 Etagen + Dachterrasse',
'hero.discover' => 'Entdecken',
// ─── Facts strip ─────────────────────────────────────────────────────
'facts.area' => 'm² Wohnfläche',
'facts.rooms' => 'Zimmer',
'facts.floors' => 'Etagen',
'facts.rent' => '€ Kaltmiete',
// ─── Intro ───────────────────────────────────────────────────────────
'intro.eyebrow' => 'Das Objekt',
'intro.h2' => 'Wohnen mit Charakter und viel Raum',
'intro.p1' => 'Vermietet wird ein vollständiges Einfamilienhaus in ruhiger Lage von Schleusingen. Das Haus verbindet historischen Charme mit modernem Wohnkomfort auf drei großzügigen Etagen.',
'intro.p2' => 'Garage für zwei Fahrzeuge, großzügige Dachterrasse mit 35,8 m², vollausgestattete Küche, Vollbad sowie Abstell- und Nutzräume machen das Haus zu einem außergewöhnlichen Mietobjekt.',
'intro.stats.area' => 'Nutzfläche',
'intro.stats.terrace' => 'Dachterrasse',
'intro.stats.garage' => 'Garage',
'intro.badge' => 'Wohnzimmer · 42,6 m²',
// ─── Gallery ─────────────────────────────────────────────────────────
'gallery.aria' => 'Fotogalerie',
'gallery.eyebrow' => 'Fotogalerie',
'gallery.h2' => 'Einblicke ins Haus',
'gallery.zoom' => ' Großansicht öffnen',
'gallery.exterior' => 'Außenansicht',
'gallery.living' => 'Wohnzimmer',
'gallery.living_area' => 'Wohnzimmer · 42,6 m²',
'gallery.kitchen' => 'Küche · 18,4 m²',
'gallery.bedroom' => 'Schlafzimmer · 18 m²',
'gallery.bath' => 'Badezimmer · 9,8 m²',
'gallery.kid1' => 'Kinderzimmer 1 · 21,7 m²',
'gallery.kid2' => 'Kinderzimmer 2 · 15,7 m²',
'gallery.kid_detail' => 'Kinderzimmer Detail',
'gallery.guest' => 'Gästezimmer · 11,5 m²',
'gallery.area1' => 'Wohnbereich',
'gallery.area2' => 'Wohnbereich Detail',
'gallery.area3' => 'Hausansicht',
'gallery.alt.living' => 'Wohnzimmer mit 42,6 m² Wohnfläche',
'gallery.alt.kitchen' => 'Küche mit 18,4 m²',
'gallery.alt.bedroom' => 'Schlafzimmer mit 18 m²',
'gallery.alt.bath' => 'Badezimmer mit 9,8 m²',
'gallery.alt.kid1' => 'Kinderzimmer 1 mit 21,7 m²',
'gallery.alt.kid2' => 'Kinderzimmer 2 mit 15,7 m²',
'gallery.alt.kid_detail' => 'Detailansicht Kinderzimmer',
'gallery.alt.guest' => 'Gästezimmer mit 11,5 m²',
'gallery.alt.bath2' => 'Zweites Badezimmer im Haus',
'gallery.alt.bath3' => 'Drittes Badezimmer im Haus',
'gallery.alt.exterior' => 'Außenansicht des Einfamilienhauses',
// ─── Floor plans (Grundriss) ────────────────────────────────────────
'floors.eyebrow' => 'Raumaufteilung',
'floors.h2' => 'Großzügig auf allen Etagen',
'floors.eg.title' => 'Erdgeschoss',
'floors.og1.title' => '1. Obergeschoss',
'floors.og2.title' => '2. Obergeschoss',
'floors.attic.title' => 'Dachboden',
'floors.eg.area' => '99,5 m²',
'floors.og1.area' => '120,4 m²',
'floors.og2.area' => '68 m²',
'floors.attic.area' => '94 m² Nutzfläche',
'floors.room.hall' => 'Flur',
'floors.room.wc' => 'WC',
'floors.room.garage' => 'Garage / Partykeller',
'floors.room.storage1' => 'Abstellraum 1',
'floors.room.storage2' => 'Abstellraum 2',
'floors.room.heating' => 'Heizungskeller',
'floors.room.living' => 'Wohnzimmer',
'floors.room.guest' => 'Gästezimmer',
'floors.room.bath' => 'Badezimmer',
'floors.room.kitchen' => 'Küche',
'floors.room.bedroom' => 'Schlafzimmer',
'floors.room.kid1' => 'Kinderzimmer 1',
'floors.room.kid2' => 'Kinderzimmer 2',
'floors.room.play' => 'Spielzimmer',
'floors.room.dressing' => 'Ankleidezimmer',
'floors.room.terrace' => 'Dachterrasse',
'floors.room.terrace_note' => '(25% von 35,8 m²)',
'floors.room.attic_low' => 'Dachboden unten (ungeheizt)',
'floors.room.attic_mid' => 'Dachboden Mitte (ungeheizt)',
'floors.room.attic_high' => 'Dachboden oben (ungeheizt)',
'floors.alt.eg' => 'Grundriss Erdgeschoss',
'floors.alt.og1' => 'Grundriss 1. Obergeschoss',
'floors.alt.og2' => 'Grundriss 2. Obergeschoss',
'floors.alt.attic' => 'Grundriss Dachboden',
// ─── Rent (Miete) ────────────────────────────────────────────────────
'rent.eyebrow' => 'Mietkonditionen',
'rent.aria' => 'Mietkonditionen',
'rent.h2' => 'Transparente Preisgestaltung',
'rent.cold' => 'Kaltmiete',
'rent.warm' => 'Gesamtmiete warm',
'rent.deposit' => 'Kaution',
'rent.per_month' => 'pro Monat',
'rent.warm_includes' => 'inkl. 300 € Nebenkosten',
'rent.deposit_months' => '2 Nettokaltmieten',
'rent.note.available' => 'Verfügbarkeit',
'rent.note.available_val'=> 'Ab sofort · unbefristete Laufzeit',
'rent.note.costs' => 'Nebenkosten',
'rent.note.costs_val' => 'Vorauszahlung 300 €/Monat, jährliche Abrechnung',
'rent.note.energy' => 'Energieausweis',
'rent.note.energy_val' => 'Wird bei Mietbeginn übergeben · Erdgasheizung',
'rent.note.pets' => 'Haustiere',
'rent.note.pets_val' => 'Auf Anfrage',
// ─── Location (Lage) ─────────────────────────────────────────────────
'loc.eyebrow' => 'Standort',
'loc.h2' => 'Zentral und ruhig zugleich',
'loc.shopping' => 'Einkaufen & Versorgung',
'loc.shopping_desc' => 'Supermärkte, Ärzte, Apotheken und Schulen sind fußläufig erreichbar',
'loc.transport' => 'Öffentlicher Nahverkehr',
'loc.transport_desc' => 'Zentrale Bushaltestelle ca. 200 m entfernt — direkte Verbindungen in die Region',
'loc.center' => 'Innenstadt Schleusingen',
'loc.center_desc' => 'Wochenmarkt und Stadtmitte nur ca. 500 m entfernt',
'loc.address' => 'Genaue Adresse',
'loc.address_val' => 'Schleusinger Bahnhofstraße 10<br />98553 Schleusingen, Thüringen',
'loc.map_title' => 'Standort Bahnhofstraße 10, Schleusingen',
// ─── Contact ─────────────────────────────────────────────────────────
'contact.eyebrow' => 'Kontakt',
'contact.aria' => 'Kontaktformular',
'contact.h2' => 'Interesse?',
'contact.h2_em' => 'Schreiben Sie uns.',
'contact.intro' => 'Wir freuen uns über Ihre Anfrage und melden uns innerhalb von 24 Stunden. Besichtigungstermine sind nach Absprache möglich. Bitte geben Sie bei Ihrer Anfrage ein paar Terminvorschläge an.',
'contact.success' => 'Vielen Dank für Ihre Anfrage!',
'contact.success_sub' => 'Wir haben Ihre Nachricht erhalten und melden uns innerhalb von 24 Stunden bei Ihnen.',
'contact.fname' => 'Vorname',
'contact.lname' => 'Nachname',
'contact.email' => 'E-Mail',
'contact.phone' => 'Telefon',
'contact.interest' => 'Anliegen',
'contact.interest_visit' => 'Besichtigung anfragen',
'contact.interest_info' => 'Allgemeine Informationen',
'contact.interest_apply' => 'Mietbewerbung einreichen',
'contact.message' => 'Nachricht',
'contact.submit' => 'Anfrage absenden',
'contact.hp_label' => 'Website',
'contact.direct' => 'Oder schreiben Sie uns direkt:',
// ─── Footer ──────────────────────────────────────────────────────────
'footer.address' => 'Bahnhofstraße 10 · Schleusingen',
'footer.imprint' => 'Impressum',
'footer.privacy' => 'Datenschutz',
'footer.aria' => 'Fußbereich',
'a11y.main' => 'Hauptinhalt',
// ─── Lightbox ────────────────────────────────────────────────────────
'lightbox.aria' => 'Bildansicht',
'lightbox.close' => 'Bildansicht schließen',
// ─── Legal pages ─────────────────────────────────────────────────────
'legal.back' => '← Zurück zum Objekt',
'legal.german_only' => 'Diese Seite ist nur auf Deutsch verfügbar.',
'legal.imprint_eyebrow' => 'Pflichtangaben',
'legal.imprint_h1' => 'Impressum',
'legal.privacy_eyebrow' => 'Datenschutz',
'legal.privacy_h1' => 'Datenschutzerklärung',
// ─── Locale switcher (UI) ────────────────────────────────────────────
'locale.switcher.aria' => 'Sprache wählen',
'locale.de' => 'Deutsch',
'locale.en' => 'English',
'locale.uk' => 'Українська',
'locale.ru' => 'Русский',
'locale.current' => 'Aktuelle Sprache: {lang}',
// ─── Form errors (keys are referenced by the controller) ────────────
'form.error.csrf' => 'Sicherheitsüberprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.',
'form.error.fname_required' => 'Bitte geben Sie Ihren Vornamen an.',
'form.error.lname_required' => 'Bitte geben Sie Ihren Nachnamen an.',
'form.error.email_invalid' => 'Bitte geben Sie eine gültige E-Mail-Adresse an.',
'form.error.message_required' => 'Bitte geben Sie eine Nachricht ein.',
'form.error.header_injection' => 'Ungültige Zeichen in den Eingabefeldern.',
'form.error.too_fast' => 'Das Formular wurde zu schnell abgeschickt. Bitte versuchen Sie es erneut.',
'form.error.rate_limit' => 'Bitte warten Sie einen Moment vor der nächsten Anfrage.',
'form.error.send_failed' => 'Leider konnte die E-Mail nicht gesendet werden. Bitte versuchen Sie es später erneut oder schreiben Sie uns direkt an mki@kies-media.de.',
// ─── Form interest options ──────────────────────────────────────────
'form.interest.visit' => 'Besichtigung anfragen',
'form.interest.info' => 'Allgemeine Informationen',
'form.interest.apply' => 'Mietbewerbung einreichen',
// ─── Misc ───────────────────────────────────────────────────────────
'nav.back_home' => '← Zurück zur Startseite',
// ─── Address & structured data (JSON-LD, search engines) ───────────
'address.street' => 'Bahnhofstraße 10',
'address.city' => 'Schleusingen',
'structured.listing_name' => 'Einfamilienhaus zur Miete in Schleusingen',
'structured.listing_description' => 'Großzügiges Einfamilienhaus zur Langzeitmiete: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €.',
'structured.price_description' => 'Kaltmiete pro Monat',
];

207
app/Locales/en.php Normal file
View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
/**
* English (EN) translations.
* @see de.php for key reference.
*/
return [
'site.name' => 'House Schleusingen',
'site.title' => 'Detached house for rent in Schleusingen | 227 m², 6 rooms | €1,300 cold rent',
'site.description' => 'Detached house for long-term rental in Schleusingen: 227 m² living space, 6 rooms, 3 floors with rooftop terrace. Cold rent €1,300. Bahnhofstraße 10, 98553 Schleusingen. Available immediately.',
'site.og_locale' => 'en_US',
'site.canonical_base' => 'https://haus-schleusingen.de/en',
'nav.skip' => 'Skip to content',
'nav.main' => 'Main navigation',
'nav.toggle' => 'Open navigation',
'nav.gallery' => 'Gallery',
'nav.layout' => 'Floor plan',
'nav.rent' => 'Rent',
'nav.location' => 'Location',
'nav.cta' => 'Inquire now',
'hero.tag' => 'Long-term rental · Available immediately',
'hero.h1.line1' => 'Spacious',
'hero.h1.line2' => 'Detached house',
'hero.h1.line3' => 'in Schleusingen',
'hero.address' => 'Schleusinger Bahnhofstraße 10',
'hero.area' => '227 m² living space',
'hero.rooms' => '6 rooms',
'hero.floors' => '3 floors + rooftop terrace',
'hero.discover' => 'Discover',
'facts.area' => 'm² living space',
'facts.rooms' => 'rooms',
'facts.floors' => 'floors',
'facts.rent' => '€ cold rent',
'intro.eyebrow' => 'The property',
'intro.h2' => 'Living with character and plenty of space',
'intro.p1' => 'A complete detached house in a quiet part of Schleusingen is being rented out. The house combines historic charm with modern living comfort across three spacious floors.',
'intro.p2' => 'A two-car garage, a generous 35.8 m² rooftop terrace, a fully equipped kitchen, a full bathroom plus storage and utility rooms make this an exceptional rental property.',
'intro.stats.area' => 'Usable area',
'intro.stats.terrace' => 'Rooftop terrace',
'intro.stats.garage' => 'Garage',
'intro.badge' => 'Living room · 42.6 m²',
'gallery.aria' => 'Photo gallery',
'gallery.eyebrow' => 'Photo gallery',
'gallery.h2' => 'A look inside',
'gallery.zoom' => ' open full view',
'gallery.exterior' => 'Exterior view',
'gallery.living' => 'Living room',
'gallery.living_area' => 'Living room · 42.6 m²',
'gallery.kitchen' => 'Kitchen · 18.4 m²',
'gallery.bedroom' => 'Bedroom · 18 m²',
'gallery.bath' => 'Bathroom · 9.8 m²',
'gallery.kid1' => 'Child\'s room 1 · 21.7 m²',
'gallery.kid2' => 'Child\'s room 2 · 15.7 m²',
'gallery.kid_detail' => 'Child\'s room detail',
'gallery.guest' => 'Guest room · 11.5 m²',
'gallery.area1' => 'Living area',
'gallery.area2' => 'Living area detail',
'gallery.area3' => 'House view',
'gallery.alt.living' => 'Living room with 42.6 m² of floor space',
'gallery.alt.kitchen' => 'Kitchen with 18.4 m²',
'gallery.alt.bedroom' => 'Bedroom with 18 m²',
'gallery.alt.bath' => 'Bathroom with 9.8 m²',
'gallery.alt.kid1' => 'Child\'s room 1 with 21.7 m²',
'gallery.alt.kid2' => 'Child\'s room 2 with 15.7 m²',
'gallery.alt.kid_detail' => 'Child\'s room detail view',
'gallery.alt.guest' => 'Guest room with 11.5 m²',
'gallery.alt.bath2' => 'Second bathroom in the house',
'gallery.alt.bath3' => 'Third bathroom in the house',
'gallery.alt.exterior' => 'Exterior view of the detached house',
'floors.eyebrow' => 'Layout',
'floors.h2' => 'Spacious on every floor',
'floors.eg.title' => 'Ground floor',
'floors.og1.title' => '1st upper floor',
'floors.og2.title' => '2nd upper floor',
'floors.attic.title' => 'Attic',
'floors.eg.area' => '99.5 m²',
'floors.og1.area' => '120.4 m²',
'floors.og2.area' => '68 m²',
'floors.attic.area' => '94 m² usable area',
'floors.room.hall' => 'Hallway',
'floors.room.wc' => 'WC',
'floors.room.garage' => 'Garage / party room',
'floors.room.storage1' => 'Storage room 1',
'floors.room.storage2' => 'Storage room 2',
'floors.room.heating' => 'Heating cellar',
'floors.room.living' => 'Living room',
'floors.room.guest' => 'Guest room',
'floors.room.bath' => 'Bathroom',
'floors.room.kitchen' => 'Kitchen',
'floors.room.bedroom' => 'Bedroom',
'floors.room.kid1' => 'Child\'s room 1',
'floors.room.kid2' => 'Child\'s room 2',
'floors.room.play' => 'Playroom',
'floors.room.dressing' => 'Dressing room',
'floors.room.terrace' => 'Rooftop terrace',
'floors.room.terrace_note' => '(25% of 35.8 m²)',
'floors.room.attic_low' => 'Attic lower (unheated)',
'floors.room.attic_mid' => 'Attic middle (unheated)',
'floors.room.attic_high' => 'Attic upper (unheated)',
'floors.alt.eg' => 'Floor plan ground floor',
'floors.alt.og1' => 'Floor plan 1st upper floor',
'floors.alt.og2' => 'Floor plan 2nd upper floor',
'floors.alt.attic' => 'Floor plan attic',
'rent.eyebrow' => 'Rental terms',
'rent.aria' => 'Rental terms',
'rent.h2' => 'Transparent pricing',
'rent.cold' => 'Cold rent',
'rent.warm' => 'Total rent (incl. utilities)',
'rent.deposit' => 'Deposit',
'rent.per_month' => 'per month',
'rent.warm_includes' => 'incl. €300 utilities',
'rent.deposit_months' => '2 net cold rents',
'rent.note.available' => 'Availability',
'rent.note.available_val'=> 'Immediately · indefinite term',
'rent.note.costs' => 'Utilities',
'rent.note.costs_val' => 'Advance payment €300/month, annual settlement',
'rent.note.energy' => 'Energy certificate',
'rent.note.energy_val' => 'Handed over at start of tenancy · natural gas heating',
'rent.note.pets' => 'Pets',
'rent.note.pets_val' => 'On request',
'loc.eyebrow' => 'Location',
'loc.h2' => 'Central and quiet at the same time',
'loc.shopping' => 'Shopping & supplies',
'loc.shopping_desc' => 'Supermarkets, doctors, pharmacies and schools within walking distance',
'loc.transport' => 'Public transport',
'loc.transport_desc' => 'Central bus stop about 200 m away — direct connections in the region',
'loc.center' => 'Schleusingen town centre',
'loc.center_desc' => 'Weekly market and town centre just about 500 m away',
'loc.address' => 'Exact address',
'loc.address_val' => 'Schleusinger Bahnhofstraße 10<br />98553 Schleusingen, Thuringia',
'loc.map_title' => 'Location Bahnhofstraße 10, Schleusingen',
'contact.eyebrow' => 'Contact',
'contact.aria' => 'Contact form',
'contact.h2' => 'Interested?',
'contact.h2_em' => 'Get in touch.',
'contact.intro' => 'We look forward to your inquiry and will get back to you within 24 hours. Viewing appointments can be arranged by agreement. Please include a few date suggestions in your request.',
'contact.success' => 'Thank you for your inquiry!',
'contact.success_sub' => 'We have received your message and will get back to you within 24 hours.',
'contact.fname' => 'First name',
'contact.lname' => 'Last name',
'contact.email' => 'Email',
'contact.phone' => 'Phone',
'contact.interest' => 'Subject',
'contact.interest_visit' => 'Request a viewing',
'contact.interest_info' => 'General information',
'contact.interest_apply' => 'Submit rental application',
'contact.message' => 'Message',
'contact.submit' => 'Send inquiry',
'contact.hp_label' => 'Website',
'contact.direct' => 'Or write to us directly:',
'footer.address' => 'Bahnhofstraße 10 · Schleusingen',
'footer.imprint' => 'Imprint',
'footer.privacy' => 'Privacy policy',
'footer.aria' => 'Footer',
'a11y.main' => 'Main content',
'lightbox.aria' => 'Image view',
'lightbox.close' => 'Close image view',
'legal.back' => '← Back to the property',
'legal.german_only' => 'This page is available in German only.',
'legal.imprint_eyebrow' => 'Mandatory information',
'legal.imprint_h1' => 'Imprint',
'legal.privacy_eyebrow' => 'Privacy',
'legal.privacy_h1' => 'Privacy policy',
'locale.switcher.aria' => 'Choose language',
'locale.de' => 'Deutsch',
'locale.en' => 'English',
'locale.uk' => 'Українська',
'locale.ru' => 'Русский',
'locale.current' => 'Current language: {lang}',
'form.error.csrf' => 'Security check failed. Please try again.',
'form.error.fname_required' => 'Please enter your first name.',
'form.error.lname_required' => 'Please enter your last name.',
'form.error.email_invalid' => 'Please enter a valid email address.',
'form.error.message_required' => 'Please enter a message.',
'form.error.header_injection' => 'Invalid characters in the input fields.',
'form.error.too_fast' => 'The form was submitted too quickly. Please try again.',
'form.error.rate_limit' => 'Please wait a moment before sending another request.',
'form.error.send_failed' => 'Unfortunately the email could not be sent. Please try again later or write to us directly at mki@kies-media.de.',
'form.interest.visit' => 'Request a viewing',
'form.interest.info' => 'General information',
'form.interest.apply' => 'Submit rental application',
'nav.back_home' => '← Back to home',
'address.street' => 'Bahnhofstraße 10',
'address.city' => 'Schleusingen',
'structured.listing_name' => 'Family house for rent in Schleusingen',
'structured.listing_description' => 'Spacious family house for long-term rent: 227 m² of living space, 6 rooms, 3 floors with rooftop terrace. Cold rent €1,300.',
'structured.price_description' => 'Cold rent per month',
];

207
app/Locales/ru.php Normal file
View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
/**
* Russian (RU) translations.
* @see de.php for key reference.
*/
return [
'site.name' => 'Дом Шлайзинген',
'site.title' => 'Аренда частного дома в Шлайзингене | 227 м², 6 комнат | 1 300 €',
'site.description' => 'Частный дом для долгосрочной аренды в Шлайзингене: 227 м² жилой площади, 6 комнат, 3 этажа с террасой на крыше. Аренда 1 300 €. Bahnhofstraße 10, 98553 Schleusingen. Свободен сразу.',
'site.og_locale' => 'ru_RU',
'site.canonical_base' => 'https://haus-schleusingen.de/ru',
'nav.skip' => 'Перейти к содержимому',
'nav.main' => 'Главная навигация',
'nav.toggle' => 'Открыть навигацию',
'nav.gallery' => 'Галерея',
'nav.layout' => 'Планировка',
'nav.rent' => 'Аренда',
'nav.location' => 'Расположение',
'nav.cta' => 'Связаться',
'hero.tag' => 'Долгосрочная аренда · Свободен сразу',
'hero.h1.line1' => 'Просторный',
'hero.h1.line2' => 'Частный дом',
'hero.h1.line3' => 'в Шлайзингене',
'hero.address' => 'Schleusinger Bahnhofstraße 10',
'hero.area' => '227 м² жилой площади',
'hero.rooms' => '6 комнат',
'hero.floors' => '3 этажа + терраса на крыше',
'hero.discover' => 'Узнать больше',
'facts.area' => 'м² жилой площади',
'facts.rooms' => 'комнат',
'facts.floors' => 'этажей',
'facts.rent' => '€ аренда',
'intro.eyebrow' => 'Объект',
'intro.h2' => 'Жильё с характером и большим пространством',
'intro.p1' => 'Сдаётся в аренду полноценный частный дом в тихом районе Шлайзингена. Дом сочетает историческое очарование с современным комфортом на трёх просторных этажах.',
'intro.p2' => 'Гараж на два автомобиля, просторная терраса на крыше 35,8 м², полностью оборудованная кухня, полноценная ванная комната, а также кладовые и подсобные помещения делают этот дом исключительным арендным объектом.',
'intro.stats.area' => 'Полезная площадь',
'intro.stats.terrace' => 'Терраса на крыше',
'intro.stats.garage' => 'Гараж',
'intro.badge' => 'Гостиная · 42,6 м²',
'gallery.aria' => 'Фотогалерея',
'gallery.eyebrow' => 'Фотогалерея',
'gallery.h2' => 'Взгляд на дом',
'gallery.zoom' => ' открыть полный вид',
'gallery.exterior' => 'Внешний вид',
'gallery.living' => 'Гостиная',
'gallery.living_area' => 'Гостиная · 42,6 м²',
'gallery.kitchen' => 'Кухня · 18,4 м²',
'gallery.bedroom' => 'Спальня · 18 м²',
'gallery.bath' => 'Ванная комната · 9,8 м²',
'gallery.kid1' => 'Детская 1 · 21,7 м²',
'gallery.kid2' => 'Детская 2 · 15,7 м²',
'gallery.kid_detail' => 'Деталь детской',
'gallery.guest' => 'Гостевая комната · 11,5 м²',
'gallery.area1' => 'Жилая зона',
'gallery.area2' => 'Деталь жилой зоны',
'gallery.area3' => 'Вид на дом',
'gallery.alt.living' => 'Гостиная площадью 42,6 м²',
'gallery.alt.kitchen' => 'Кухня 18,4 м²',
'gallery.alt.bedroom' => 'Спальня 18 м²',
'gallery.alt.bath' => 'Ванная комната 9,8 м²',
'gallery.alt.kid1' => 'Детская комната 1 — 21,7 м²',
'gallery.alt.kid2' => 'Детская комната 2 — 15,7 м²',
'gallery.alt.kid_detail' => 'Детальный вид детской комнаты',
'gallery.alt.guest' => 'Гостевая комната 11,5 м²',
'gallery.alt.bath2' => 'Вторая ванная комната в доме',
'gallery.alt.bath3' => 'Третья ванная комната в доме',
'gallery.alt.exterior' => 'Внешний вид частного дома',
'floors.eyebrow' => 'Планировка',
'floors.h2' => 'Просторно на каждом этаже',
'floors.eg.title' => 'Первый этаж',
'floors.og1.title' => 'Второй этаж',
'floors.og2.title' => 'Третий этаж',
'floors.attic.title' => 'Чердак',
'floors.eg.area' => '99,5 м²',
'floors.og1.area' => '120,4 м²',
'floors.og2.area' => '68 м²',
'floors.attic.area' => '94 м² полезной площади',
'floors.room.hall' => 'Прихожая',
'floors.room.wc' => 'Туалет',
'floors.room.garage' => 'Гараж / комната для вечеринок',
'floors.room.storage1' => 'Кладовая 1',
'floors.room.storage2' => 'Кладовая 2',
'floors.room.heating' => 'Котельная',
'floors.room.living' => 'Гостиная',
'floors.room.guest' => 'Гостевая комната',
'floors.room.bath' => 'Ванная комната',
'floors.room.kitchen' => 'Кухня',
'floors.room.bedroom' => 'Спальня',
'floors.room.kid1' => 'Детская комната 1',
'floors.room.kid2' => 'Детская комната 2',
'floors.room.play' => 'Игровая комната',
'floors.room.dressing' => 'Гардеробная',
'floors.room.terrace' => 'Терраса на крыше',
'floors.room.terrace_note' => '(25% от 35,8 м²)',
'floors.room.attic_low' => 'Чердак нижний (неотапливаемый)',
'floors.room.attic_mid' => 'Чердак средний (неотапливаемый)',
'floors.room.attic_high' => 'Чердак верхний (неотапливаемый)',
'floors.alt.eg' => 'План первого этажа',
'floors.alt.og1' => 'План второго этажа',
'floors.alt.og2' => 'План третьего этажа',
'floors.alt.attic' => 'План чердака',
'rent.eyebrow' => 'Условия аренды',
'rent.aria' => 'Условия аренды',
'rent.h2' => 'Прозрачное ценообразование',
'rent.cold' => 'Базовая аренда',
'rent.warm' => 'Общая аренда',
'rent.deposit' => 'Залог',
'rent.per_month' => 'в месяц',
'rent.warm_includes' => 'вкл. 300 € доп. расходов',
'rent.deposit_months' => '2 базовые аренды',
'rent.note.available' => 'Доступность',
'rent.note.available_val'=> 'Сразу · бессрочно',
'rent.note.costs' => 'Доп. расходы',
'rent.note.costs_val' => 'Аванс 300 €/месяц, годовой расчёт',
'rent.note.energy' => 'Энергетический паспорт',
'rent.note.energy_val' => 'Передаётся при начале аренды · газовое отопление',
'rent.note.pets' => 'Домашние животные',
'rent.note.pets_val' => 'По запросу',
'loc.eyebrow' => 'Расположение',
'loc.h2' => 'Центрально и тихо одновременно',
'loc.shopping' => 'Магазины и услуги',
'loc.shopping_desc' => 'Супермаркеты, врачи, аптеки и школы в пешей доступности',
'loc.transport' => 'Общественный транспорт',
'loc.transport_desc' => 'Центральная автобусная остановка примерно в 200 м — прямые сообщения в регионе',
'loc.center' => 'Центр Шлайзингена',
'loc.center_desc' => 'Еженедельный рынок и центр города всего в 500 м',
'loc.address' => 'Точный адрес',
'loc.address_val' => 'Schleusinger Bahnhofstraße 10<br />98553 Schleusingen, Тюрингия',
'loc.map_title' => 'Расположение Bahnhofstraße 10, Шлайзинген',
'contact.eyebrow' => 'Контакт',
'contact.aria' => 'Контактная форма',
'contact.h2' => 'Интересно?',
'contact.h2_em' => 'Напишите нам.',
'contact.intro' => 'Мы рады вашему запросу и ответим в течение 24 часов. Осмотры возможны по договорённости. Пожалуйста, укажите в запросе несколько возможных дат.',
'contact.success' => 'Спасибо за ваш запрос!',
'contact.success_sub' => 'Мы получили ваше сообщение и свяжемся с вами в течение 24 часов.',
'contact.fname' => 'Имя',
'contact.lname' => 'Фамилия',
'contact.email' => 'Электронная почта',
'contact.phone' => 'Телефон',
'contact.interest' => 'Тема',
'contact.interest_visit' => 'Запрос на осмотр',
'contact.interest_info' => 'Общая информация',
'contact.interest_apply' => 'Подать заявку на аренду',
'contact.message' => 'Сообщение',
'contact.submit' => 'Отправить запрос',
'contact.hp_label' => 'Веб-сайт',
'contact.direct' => 'Или напишите нам напрямую:',
'footer.address' => 'Bahnhofstraße 10 · Шлайзинген',
'footer.imprint' => 'Импрессум',
'footer.privacy' => 'Политика конфиденциальности',
'footer.aria' => 'Подвал сайта',
'a11y.main' => 'Основное содержимое',
'lightbox.aria' => 'Просмотр изображения',
'lightbox.close' => 'Закрыть просмотр изображения',
'legal.back' => '← Назад к объекту',
'legal.german_only' => 'Эта страница доступна только на немецком.',
'legal.imprint_eyebrow' => 'Обязательная информация',
'legal.imprint_h1' => 'Импрессум',
'legal.privacy_eyebrow' => 'Конфиденциальность',
'legal.privacy_h1' => 'Политика конфиденциальности',
'locale.switcher.aria' => 'Выбрать язык',
'locale.de' => 'Deutsch',
'locale.en' => 'English',
'locale.uk' => 'Українська',
'locale.ru' => 'Русский',
'locale.current' => 'Текущий язык: {lang}',
'form.error.csrf' => 'Ошибка проверки безопасности. Пожалуйста, попробуйте ещё раз.',
'form.error.fname_required' => 'Пожалуйста, укажите ваше имя.',
'form.error.lname_required' => 'Пожалуйста, укажите вашу фамилию.',
'form.error.email_invalid' => 'Пожалуйста, введите корректный адрес электронной почты.',
'form.error.message_required' => 'Пожалуйста, введите сообщение.',
'form.error.header_injection' => 'Недопустимые символы в полях ввода.',
'form.error.too_fast' => 'Форма была отправлена слишком быстро. Пожалуйста, попробуйте ещё раз.',
'form.error.rate_limit' => 'Пожалуйста, подождите немного перед следующим запросом.',
'form.error.send_failed' => 'К сожалению, не удалось отправить письмо. Попробуйте позже или напишите нам напрямую на mki@kies-media.de.',
'form.interest.visit' => 'Запрос на осмотр',
'form.interest.info' => 'Общая информация',
'form.interest.apply' => 'Подать заявку на аренду',
'nav.back_home' => '← На главную',
'address.street' => 'Bahnhofstraße 10',
'address.city' => 'Шлайзинген',
'structured.listing_name' => 'Дом в аренду в Шлайзингене',
'structured.listing_description' => 'Просторный дом для долгосрочной аренды: 227 м² жилой площади, 6 комнат, 3 этажа с террасой на крыше. Базовая аренда 1 300 €.',
'structured.price_description' => 'Базовая аренда в месяц',
];

207
app/Locales/uk.php Normal file
View File

@@ -0,0 +1,207 @@
<?php
declare(strict_types=1);
/**
* Ukrainian (UK) translations.
* @see de.php for key reference.
*/
return [
'site.name' => 'Будинок Шлайзінген',
'site.title' => 'Оренда приватного будинку в Шлайзінгені | 227 м², 6 кімнат | 1 300 €',
'site.description' => 'Приватний будинок для довгострокової оренди в Шлайзінгені: 227 м² житлової площі, 6 кімнат, 3 поверхи з терасою на даху. Оренда 1 300 €. Bahnhofstraße 10, 98553 Schleusingen. Доступний негайно.',
'site.og_locale' => 'uk_UA',
'site.canonical_base' => 'https://haus-schleusingen.de/uk',
'nav.skip' => 'Перейти до вмісту',
'nav.main' => 'Головна навігація',
'nav.toggle' => 'Відкрити навігацію',
'nav.gallery' => 'Галерея',
'nav.layout' => 'Планування',
'nav.rent' => 'Оренда',
'nav.location' => 'Розташування',
'nav.cta' => 'Зв\'язатися',
'hero.tag' => 'Довгострокова оренда · Доступно негайно',
'hero.h1.line1' => 'Просторий',
'hero.h1.line2' => 'Приватний будинок',
'hero.h1.line3' => 'у Шлайзінгені',
'hero.address' => 'Schleusinger Bahnhofstraße 10',
'hero.area' => '227 м² житлової площі',
'hero.rooms' => '6 кімнат',
'hero.floors' => '3 поверхи + тераса на даху',
'hero.discover' => 'Дізнатися більше',
'facts.area' => 'м² житлової площі',
'facts.rooms' => 'кімнат',
'facts.floors' => 'поверхи',
'facts.rent' => '€ оренда',
'intro.eyebrow' => 'Об\'єкт',
'intro.h2' => 'Житло з характером і великим простором',
'intro.p1' => 'Здається в оренду повноцінний приватний будинок у тихому районі Шлайзінгена. Будинок поєднує історичний шарм із сучасним комфортом на трьох просторих поверхах.',
'intro.p2' => 'Гараж на два автомобілі, простора тераса на даху 35,8 м², повністю обладнана кухня, повноцінна ванна кімната, а також комори та підсобні приміщення роблять цей будинок винятковим орендним об\'єктом.',
'intro.stats.area' => 'Корисна площа',
'intro.stats.terrace' => 'Тераса на даху',
'intro.stats.garage' => 'Гараж',
'intro.badge' => 'Вітальня · 42,6 м²',
'gallery.aria' => 'Фотогалерея',
'gallery.eyebrow' => 'Фотогалерея',
'gallery.h2' => 'Погляд на будинок',
'gallery.zoom' => ' відкрити повний перегляд',
'gallery.exterior' => 'Зовнішній вигляд',
'gallery.living' => 'Вітальня',
'gallery.living_area' => 'Вітальня · 42,6 м²',
'gallery.kitchen' => 'Кухня · 18,4 м²',
'gallery.bedroom' => 'Спальня · 18 м²',
'gallery.bath' => 'Ванна кімната · 9,8 м²',
'gallery.kid1' => 'Дитяча кімната 1 · 21,7 м²',
'gallery.kid2' => 'Дитяча кімната 2 · 15,7 м²',
'gallery.kid_detail' => 'Деталь дитячої кімнати',
'gallery.guest' => 'Гостьова кімната · 11,5 м²',
'gallery.area1' => 'Житлова зона',
'gallery.area2' => 'Деталь житлової зони',
'gallery.area3' => 'Вид на будинок',
'gallery.alt.living' => 'Вітальня з площею 42,6 м²',
'gallery.alt.kitchen' => 'Кухня 18,4 м²',
'gallery.alt.bedroom' => 'Спальня 18 м²',
'gallery.alt.bath' => 'Ванна кімната 9,8 м²',
'gallery.alt.kid1' => 'Дитяча кімната 1 — 21,7 м²',
'gallery.alt.kid2' => 'Дитяча кімната 2 — 15,7 м²',
'gallery.alt.kid_detail' => 'Детальний вигляд дитячої кімнати',
'gallery.alt.guest' => 'Гостьова кімната 11,5 м²',
'gallery.alt.bath2' => 'Друга ванна кімната в будинку',
'gallery.alt.bath3' => 'Третя ванна кімната в будинку',
'gallery.alt.exterior' => 'Зовнішній вигляд приватного будинку',
'floors.eyebrow' => 'Планування',
'floors.h2' => 'Просторий на кожному поверсі',
'floors.eg.title' => 'Перший поверх',
'floors.og1.title' => 'Другий поверх',
'floors.og2.title' => 'Третій поверх',
'floors.attic.title' => 'Горище',
'floors.eg.area' => '99,5 м²',
'floors.og1.area' => '120,4 м²',
'floors.og2.area' => '68 м²',
'floors.attic.area' => '94 м² корисної площі',
'floors.room.hall' => 'Коридор',
'floors.room.wc' => 'Туалет',
'floors.room.garage' => 'Гараж / кімната для вечірок',
'floors.room.storage1' => 'Комора 1',
'floors.room.storage2' => 'Комора 2',
'floors.room.heating' => 'Котельня',
'floors.room.living' => 'Вітальня',
'floors.room.guest' => 'Гостьова кімната',
'floors.room.bath' => 'Ванна кімната',
'floors.room.kitchen' => 'Кухня',
'floors.room.bedroom' => 'Спальня',
'floors.room.kid1' => 'Дитяча кімната 1',
'floors.room.kid2' => 'Дитяча кімната 2',
'floors.room.play' => 'Ігрова кімната',
'floors.room.dressing' => 'Гардеробна',
'floors.room.terrace' => 'Тераса на даху',
'floors.room.terrace_note' => '(25% від 35,8 м²)',
'floors.room.attic_low' => 'Горище нижнє (неопалюване)',
'floors.room.attic_mid' => 'Горище середнє (неопалюване)',
'floors.room.attic_high' => 'Горище верхнє (неопалюване)',
'floors.alt.eg' => 'План першого поверху',
'floors.alt.og1' => 'План другого поверху',
'floors.alt.og2' => 'План третього поверху',
'floors.alt.attic' => 'План горища',
'rent.eyebrow' => 'Умови оренди',
'rent.aria' => 'Умови оренди',
'rent.h2' => 'Прозоре ціноутворення',
'rent.cold' => 'Базова оренда',
'rent.warm' => 'Загальна оренда',
'rent.deposit' => 'Застава',
'rent.per_month' => 'на місяць',
'rent.warm_includes' => 'вкл. 300 € додаткових витрат',
'rent.deposit_months' => '2 базові оренди',
'rent.note.available' => 'Доступність',
'rent.note.available_val'=> 'Негайно · безстроково',
'rent.note.costs' => 'Додаткові витрати',
'rent.note.costs_val' => 'Аванс 300 €/місяць, щорічний розрахунок',
'rent.note.energy' => 'Енергетичний паспорт',
'rent.note.energy_val' => 'Передається на початку оренди · газове опалення',
'rent.note.pets' => 'Домашні тварини',
'rent.note.pets_val' => 'За запитом',
'loc.eyebrow' => 'Розташування',
'loc.h2' => 'Центрально й тихо одночасно',
'loc.shopping' => 'Магазини та послуги',
'loc.shopping_desc' => 'Супермаркети, лікарі, аптеки та школи в пішій доступності',
'loc.transport' => 'Громадський транспорт',
'loc.transport_desc' => 'Центральна автобусна зупинка приблизно за 200 м — прямі сполучення в регіоні',
'loc.center' => 'Центр Шлайзінгена',
'loc.center_desc' => 'Щотижневий ринок і центр міста лише за 500 м',
'loc.address' => 'Точна адреса',
'loc.address_val' => 'Schleusinger Bahnhofstraße 10<br />98553 Schleusingen, Тюрингія',
'loc.map_title' => 'Розташування Bahnhofstraße 10, Шлайзінген',
'contact.eyebrow' => 'Контакт',
'contact.aria' => 'Контактна форма',
'contact.h2' => 'Цікавить?',
'contact.h2_em' => 'Напишіть нам.',
'contact.intro' => 'Ми раді вашому запиту й дамо відповідь протягом 24 годин. Огляди можливі за домовленістю. Будь ласка, вкажіть у запиті кілька можливих дат.',
'contact.success' => 'Дякуємо за ваш запит!',
'contact.success_sub' => 'Ми отримали ваше повідомлення й зв\'яжемося з вами протягом 24 годин.',
'contact.fname' => 'Ім\'я',
'contact.lname' => 'Прізвище',
'contact.email' => 'Електронна пошта',
'contact.phone' => 'Телефон',
'contact.interest' => 'Тема',
'contact.interest_visit' => 'Запит на огляд',
'contact.interest_info' => 'Загальна інформація',
'contact.interest_apply' => 'Подати заявку на оренду',
'contact.message' => 'Повідомлення',
'contact.submit' => 'Надіслати запит',
'contact.hp_label' => 'Вебсайт',
'contact.direct' => 'Або напишіть нам напряму:',
'footer.address' => 'Bahnhofstraße 10 · Шлайзінген',
'footer.imprint' => 'Імпресум',
'footer.privacy' => 'Політика конфіденційності',
'footer.aria' => 'Нижній колонтитул',
'a11y.main' => 'Головний вміст',
'lightbox.aria' => 'Перегляд зображення',
'lightbox.close' => 'Закрити перегляд зображення',
'legal.back' => '← Повернутися до об\'єкта',
'legal.german_only' => 'Ця сторінка доступна лише німецькою.',
'legal.imprint_eyebrow' => 'Обов\'язкова інформація',
'legal.imprint_h1' => 'Імпресум',
'legal.privacy_eyebrow' => 'Конфіденційність',
'legal.privacy_h1' => 'Політика конфіденційності',
'locale.switcher.aria' => 'Обрати мову',
'locale.de' => 'Deutsch',
'locale.en' => 'English',
'locale.uk' => 'Українська',
'locale.ru' => 'Русский',
'locale.current' => 'Поточна мова: {lang}',
'form.error.csrf' => 'Помилка перевірки безпеки. Будь ласка, спробуйте ще раз.',
'form.error.fname_required' => 'Будь ласка, введіть своє ім\'я.',
'form.error.lname_required' => 'Будь ласка, введіть своє прізвище.',
'form.error.email_invalid' => 'Будь ласка, введіть дійсну адресу електронної пошти.',
'form.error.message_required' => 'Будь ласка, введіть повідомлення.',
'form.error.header_injection' => 'Неприпустимі символи в полях введення.',
'form.error.too_fast' => 'Форму було надіслано занадто швидко. Будь ласка, спробуйте ще раз.',
'form.error.rate_limit' => 'Будь ласка, зачекайте деякий час перед наступним запитом.',
'form.error.send_failed' => 'На жаль, лист не вдалося надіслати. Спробуйте пізніше або напишіть нам напряму на mki@kies-media.de.',
'form.interest.visit' => 'Запит на огляд',
'form.interest.info' => 'Загальна інформація',
'form.interest.apply' => 'Подати заявку на оренду',
'nav.back_home' => '← На головну',
'address.street' => 'Bahnhofstraße 10',
'address.city' => 'Шляйзінген',
'structured.listing_name' => 'Будинок для оренди в Шляйзінгені',
'structured.listing_description' => 'Просторий будинок для довгострокової оренди: 227 м² житлової площі, 6 кімнат, 3 поверхи з терасою на даху. Базова оренда 1 300 €.',
'structured.price_description' => 'Базова оренда на місяць',
];

View File

@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Core\View;
abstract class Controller
{
protected View $view;
public function __construct()
{
$this->view = new View();
}
protected function render(string $view, array $data = [], string $layout = 'main'): void
{
foreach ($data as $key => $value) {
$this->view->assign($key, $value);
}
$this->view->render($view, $layout);
}
}

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
class DatenschutzController extends Controller
{
public function index(): void
{
$this->render('datenschutz/index', [
'pageTitle' => 'Datenschutzerklärung Haus Schleusingen',
'pageDescription' => 'Datenschutzerklärung der Website haus-schleusingen.de',
'robots' => 'noindex',
'canonical' => 'https://haus-schleusingen.de/datenschutz',
]);
}
}

View File

@@ -1,187 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
class HomeController extends Controller
{
public function index(): void
{
session_start();
// --- Helper functions ---
$normalizeContactValue = function (string $value): string {
return trim($value);
};
$escapeContactValue = function (string $value): string {
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
};
$containsHeaderInjection = function (string $value): bool {
return (bool) preg_match('/[\r\n]/', $value);
};
// --- Form processing ---
$formErrors = [];
$formSuccess = false;
if (!empty($_SESSION['form_success'])) {
$formSuccess = true;
unset($_SESSION['form_success']);
}
if (!empty($_SESSION['form_errors'])) {
$formErrors = $_SESSION['form_errors'];
unset($_SESSION['form_errors']);
}
if (!empty($_SESSION['form_data'])) {
$formData = $_SESSION['form_data'];
unset($_SESSION['form_data']);
} else {
$formData = ['fname' => '', 'lname' => '', 'email' => '', 'phone' => '', 'interest' => 'Besichtigung anfragen', 'message' => ''];
}
// CSRF-Token generieren (nach Session-Start)
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// CSRF-Token validieren
$csrfToken = $_POST['csrf_token'] ?? '';
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfToken)) {
header('Location: /#form-result');
$_SESSION['form_errors'] = ['Sicherheitsüberprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.'];
exit;
}
$formData['fname'] = $normalizeContactValue((string) ($_POST['fname'] ?? ''));
$formData['lname'] = $normalizeContactValue((string) ($_POST['lname'] ?? ''));
$formData['email'] = $normalizeContactValue((string) ($_POST['email'] ?? ''));
$formData['phone'] = $normalizeContactValue((string) ($_POST['phone'] ?? ''));
$formData['interest'] = $normalizeContactValue((string) ($_POST['interest'] ?? ''));
$formData['message'] = $normalizeContactValue((string) ($_POST['message'] ?? ''));
$honeypot = $normalizeContactValue((string) ($_POST['website'] ?? ''));
if ($honeypot !== '') {
header('Location: /#form-result');
$_SESSION['form_success'] = true;
exit;
} else {
if ($formData['fname'] === '') {
$formErrors[] = 'Bitte geben Sie Ihren Vornamen an.';
}
if ($formData['lname'] === '') {
$formErrors[] = 'Bitte geben Sie Ihren Nachnamen an.';
}
if ($formData['email'] === '' || !filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
$formErrors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
}
if ($formData['message'] === '') {
$formErrors[] = 'Bitte geben Sie eine Nachricht ein.';
}
if ($containsHeaderInjection($formData['email']) || $containsHeaderInjection($formData['fname'] . ' ' . $formData['lname'])) {
$formErrors[] = 'Ungültige Zeichen in den Eingabefeldern.';
}
$formTime = isset($_POST['form_time']) ? (int) $_POST['form_time'] : 0;
if ($formTime > 0 && (time() - $formTime) < 3) {
$formErrors[] = 'Das Formular wurde zu schnell abgeschickt. Bitte versuchen Sie es erneut.';
}
$lastSubmit = $_SESSION['last_contact_submit'] ?? 0;
if ($lastSubmit && (time() - $lastSubmit) < 60) {
$formErrors[] = 'Bitte warten Sie einen Moment vor der nächsten Anfrage.';
}
if (empty($formErrors)) {
$to = 'mki@kies-media.de';
$subject = 'Kontaktanfrage: ' . $formData['interest'];
$body = "Von: {$formData['fname']} {$formData['lname']}\n"
. "E-Mail: {$formData['email']}\n";
if ($formData['phone'] !== '') {
$body .= "Telefon: {$formData['phone']}\n";
}
$body .= "Anliegen: {$formData['interest']}\n\n"
. $formData['message'];
$headers = "From: {$formData['email']}\r\n";
$headers .= "Reply-To: {$formData['email']}\r\n";
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
$headers .= "X-Mailer: PHP/" . phpversion();
$mailSent = mail($to, $subject, $body, $headers);
if ($mailSent) {
$_SESSION['last_contact_submit'] = time();
header('Location: /#form-result');
$_SESSION['form_success'] = true;
exit;
} else {
$formErrors[] = 'Leider konnte die E-Mail nicht gesendet werden. Bitte versuchen Sie es später erneut oder schreiben Sie uns direkt an mki@kies-media.de.';
}
}
}
if (!empty($formErrors)) {
header('Location: /#form-result');
$_SESSION['form_errors'] = $formErrors;
$_SESSION['form_data'] = $formData;
exit;
}
}
$this->render('home/index', [
'formSuccess' => $formSuccess,
'formErrors' => $formErrors,
'formData' => $formData,
'escapeContactValue' => $escapeContactValue,
'pageTitle' => 'Einfamilienhaus mieten Schleusingen | 227 m², 6 Zimmer | 1.300 € Kaltmiete',
'pageDescription' => 'Einfamilienhaus zur Langzeitmiete in Schleusingen: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €. Bahnhofstraße 10, 98553 Schleusingen. Ab sofort verfügbar.',
'canonical' => 'https://haus-schleusingen.de/',
'openGraph' => [
'ogTitle' => 'Einfamilienhaus zur Miete in Schleusingen 227 m², 6 Zimmer',
'ogDescription' => 'Großzügiges Einfamilienhaus zur Langzeitmiete: 227 m², 6 Zimmer, 3 Etagen + Dachterrasse. Kaltmiete 1.300 €. Ab sofort verfügbar in Schleusingen.',
'ogImage' => 'https://haus-schleusingen.de/bilder/Außenansicht-2.png',
'ogUrl' => 'https://haus-schleusingen.de/',
],
'structuredData' => json_encode([
'@context' => 'https://schema.org',
'@type' => 'RealEstateListing',
'name' => 'Einfamilienhaus zur Miete in Schleusingen',
'description' => 'Großzügiges Einfamilienhaus zur Langzeitmiete: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €.',
'url' => 'https://haus-schleusingen.de/',
'image' => 'https://haus-schleusingen.de/bilder/Außenansicht-2.png',
'datePosted' => '2026-05-14',
'address' => [
'@type' => 'PostalAddress',
'streetAddress' => 'Bahnhofstraße 10',
'addressLocality' => 'Schleusingen',
'postalCode' => '98553',
'addressCountry' => 'DE',
],
'offers' => [
'@type' => 'Offer',
'price' => '1300',
'priceCurrency' => 'EUR',
'priceSpecification' => [
'@type' => 'UnitPriceSpecification',
'price' => '1300',
'priceCurrency' => 'EUR',
'unitCode' => 'MON',
'description' => 'Kaltmiete pro Monat',
],
],
'floorSize' => [
'@type' => 'QuantitativeValue',
'value' => '227',
'unitCode' => 'MTK',
],
'numberOfRooms' => [
'@type' => 'QuantitativeValue',
'value' => '6',
],
]),
]);
}
}

View File

@@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
class ImpressumController extends Controller
{
public function index(): void
{
$this->render('impressum/index', [
'pageTitle' => 'Impressum Haus Schleusingen',
'pageDescription' => 'Impressum der Website haus-schleusingen.de',
'robots' => 'noindex',
'canonical' => 'https://haus-schleusingen.de/impressum',
]);
}
}

View File

@@ -1,17 +1,18 @@
<nav id="navbar" class="scrolled">
<div class="nav-logo">Bahnhofstraße 10</div>
<ul class="nav-links">
<li><a href="/#galerie">Galerie</a></li>
<li><a href="/#grundriss">Grundriss</a></li>
<li><a href="/#miete">Miete</a></li>
<li><a href="/#lage">Lage</a></li>
</ul>
<a href="/#kontakt" class="nav-cta" style="text-decoration:none;">Jetzt anfragen</a>
</nav>
<?php
declare(strict_types=1);
/**
* Datenschutz — page body only (nav/footer/lightbox live in layouts/main.php).
* Legal body stays in German by design (DSGVO compliance).
*
* @var string $locale
* @var callable(string,array,string=):string $t
*/
?>
<main class="legal-page">
<div class="section-eyebrow">Datenschutz</div>
<h1>Datenschutzerklärung</h1>
<div class="section-eyebrow"><?= htmlspecialchars($t('legal.privacy_eyebrow'), ENT_QUOTES) ?></div>
<h1><?= htmlspecialchars($t('legal.privacy_h1'), ENT_QUOTES) ?></h1>
<h2>1. Verantwortliche Stelle</h2>
<address>
@@ -111,13 +112,5 @@
Zur Ausübung Ihrer Rechte wenden Sie sich bitte an: <a href="mailto:mki@kies-media.de">mki@kies-media.de</a>
</p>
<a href="/" class="legal-back"> Zurück zum Objekt</a>
<a href="/" class="legal-back"><?= htmlspecialchars($t('legal.back'), ENT_QUOTES) ?></a>
</main>
<footer>
<div class="footer-logo">Bahnhofstraße 10 · Schleusingen</div>
<div class="footer-links">
<a href="/impressum">Impressum</a>
<a href="/datenschutz">Datenschutz</a>
</div>
</footer>

View File

@@ -1,505 +1,355 @@
<a href="#main-content" class="skip-link">Zum Inhalt springen</a>
<nav id="navbar" role="navigation" aria-label="Hauptnavigation">
<div class="nav-logo">Bahnhofstraße 10</div>
<button class="nav-hamburger" aria-label="Navigation öffnen" aria-expanded="false">
<span></span>
</button>
<ul class="nav-links">
<li><a href="#galerie">Galerie</a></li>
<li><a href="#grundriss">Grundriss</a></li>
<li><a href="#miete">Miete</a></li>
<li><a href="#lage">Lage</a></li>
</ul>
<button
class="nav-cta"
onclick="document.getElementById('kontakt').scrollIntoView({behavior:'smooth'})"
>
Jetzt anfragen
</button>
</nav>
<div class="nav-mobile-overlay" aria-hidden="true"></div>
<?php
<section class="hero" id="hero">
<div
class="hero-bg"
id="heroBg"
style="background-image: url(/bilder/Außenansicht-2.webp)"
></div>
<div class="hero-overlay"></div>
declare(strict_types=1);
/**
* Home page — page body only (nav/footer/lightbox live in layouts/main.php).
*
* @var string $locale
* @var array<string,mixed> $formData
* @var list<string> $formErrors Translation keys, resolved via t()
* @var bool $formSuccess
* @var array<string,string> $interestKeys ['visit' => 'form.interest.visit', ...]
* @var callable(string):string $escapeContactValue
* @var callable(string,array,string=):string $t
*/
$gridItems = [
// NOTE: image filenames reflect the actual files in public/bilder/ on the server.
// 3 items were removed (gästezimmer / wohnbereich / wohnbereich-detail)
// because no matching files exist in the image inventory.
['img' => 'bilder/Außenansicht-2.png', 'key' => 'gallery.exterior', 'alt' => 'gallery.alt.exterior', 'class' => 'span-2 row-2'],
['img' => 'bilder/wohnzimmer2.png', 'key' => 'gallery.living', 'alt' => 'gallery.alt.living', 'class' => 'span-2 row-1'],
['img' => 'bilder/Küche 1.jpg', 'key' => 'gallery.kitchen', 'alt' => 'gallery.alt.kitchen', 'class' => ''],
['img' => 'bilder/schlafzimmer.png', 'key' => 'gallery.bedroom', 'alt' => 'gallery.alt.bedroom', 'class' => ''],
['img' => 'bilder/Bad.jpg', 'key' => 'gallery.bath', 'alt' => 'gallery.alt.bath', 'class' => ''],
['img' => 'bilder/Kinderzimmer 2.jpg', 'key' => 'gallery.kid1', 'alt' => 'gallery.alt.kid1', 'class' => ''],
['img' => 'bilder/Kinderzimmer 3.jpg', 'key' => 'gallery.kid2', 'alt' => 'gallery.alt.kid2', 'class' => ''],
['img' => 'bilder/kinderzimmer 2 2.webp', 'key' => 'gallery.kid_detail', 'alt' => 'gallery.alt.kid_detail', 'class' => 'span-2 row-1'],
['img' => 'bilder/Außenansicht-2.png', 'key' => 'gallery.area3', 'alt' => 'gallery.alt.exterior', 'class' => 'span-2 row-1'],
];
?>
<header class="hero" id="hero">
<img src="/bilder/Außenansicht-2.webp" alt="" class="hero-bg" id="heroBg" loading="eager" decoding="async" fetchpriority="high">
<div class="hero-overlay" aria-hidden="true"></div>
<div class="hero-content" id="heroContent">
<div class="hero-tag">Zur Langzeitmiete · Ab sofort verfügbar</div>
<h1>
Großzügiges
<br />
<em>Einfamilienhaus</em>
<br />
in Schleusingen
<span class="hero-tag"><?= htmlspecialchars($t('hero.tag'), ENT_QUOTES) ?></span>
<h1 class="hero-h1">
<span class="hero-line"><?= htmlspecialchars($t('hero.h1.line1'), ENT_QUOTES) ?></span>
<span class="hero-line accent"><?= htmlspecialchars($t('hero.h1.line2'), ENT_QUOTES) ?></span>
<span class="hero-line"><?= htmlspecialchars($t('hero.h1.line3'), ENT_QUOTES) ?></span>
</h1>
<div class="hero-meta">
<span><strong>Schleusinger Bahnhofstraße 10</strong></span>
<span>227 Wohnfläche</span>
<span>6 Zimmer</span>
<span>3 Etagen + Dachterrasse</span>
<ul class="hero-meta" aria-label="<?= htmlspecialchars($t('hero.address'), ENT_QUOTES) ?>">
<li class="hero-meta-item"><?= htmlspecialchars($t('hero.address'), ENT_QUOTES) ?></li>
<li class="hero-meta-item"><?= htmlspecialchars($t('hero.area'), ENT_QUOTES) ?></li>
<li class="hero-meta-item"><?= htmlspecialchars($t('hero.rooms'), ENT_QUOTES) ?></li>
<li class="hero-meta-item"><?= htmlspecialchars($t('hero.floors'), ENT_QUOTES) ?></li>
</ul>
</div>
</div>
<div class="hero-scroll">
<span>Entdecken</span>
<div class="scroll-line"></div>
</div>
</section>
</header>
<main id="main-content">
<div class="facts-strip">
<div class="fact">
<div class="fact-val">227</div>
<div class="fact-label"> Wohnfläche</div>
</div>
<div class="fact">
<div class="fact-val">6</div>
<div class="fact-label">Zimmer</div>
</div>
<div class="fact">
<div class="fact-val">3</div>
<div class="fact-label">Etagen</div>
</div>
<div class="fact">
<div class="fact-val">1.300</div>
<div class="fact-label"> Kaltmiete</div>
</div>
</div>
<section class="facts-strip" aria-label="<?= htmlspecialchars($t('intro.stats.area'), ENT_QUOTES) ?>">
<div class="fact"><span class="fact-value">227</span><span class="fact-unit"><?= htmlspecialchars($t('facts.area'), ENT_QUOTES) ?></span></div>
<div class="fact"><span class="fact-value">6</span><span class="fact-unit"><?= htmlspecialchars($t('facts.rooms'), ENT_QUOTES) ?></span></div>
<div class="fact"><span class="fact-value">3</span><span class="fact-unit"><?= htmlspecialchars($t('facts.floors'), ENT_QUOTES) ?></span></div>
<div class="fact"><span class="fact-value">1.300</span><span class="fact-unit"><?= htmlspecialchars($t('facts.rent'), ENT_QUOTES) ?></span></div>
</section>
<section class="intro" id="intro">
<div class="intro-text" data-animate>
<div class="section-eyebrow">Das Objekt</div>
<h2>Wohnen mit Charakter und viel Raum</h2>
<p>
Vermietet wird ein vollständiges Einfamilienhaus in ruhiger Lage von Schleusingen. Das
Haus verbindet historischen Charme mit modernem Wohnkomfort auf drei großzügigen Etagen.
</p>
<p>
Garage für zwei Fahrzeuge, großzügige Dachterrasse mit 35,8 , vollausgestattete Küche,
Vollbad sowie Abstell- und Nutzräume machen das Haus zu einem außergewöhnlichen
Mietobjekt.
</p>
<div class="intro-stats">
<div>
<div class="istat-val">154,9 </div>
<div class="istat-label">Nutzfläche</div>
<div class="intro-grid">
<div class="intro-text">
<span class="section-eyebrow"><?= htmlspecialchars($t('intro.eyebrow'), ENT_QUOTES) ?></span>
<h2><?= htmlspecialchars($t('intro.h2'), ENT_QUOTES) ?></h2>
<p><?= htmlspecialchars($t('intro.p1'), ENT_QUOTES) ?></p>
<p><?= htmlspecialchars($t('intro.p2'), ENT_QUOTES) ?></p>
</div>
<div>
<div class="istat-val">35,8 </div>
<div class="istat-label">Dachterrasse</div>
<aside class="intro-stats">
<div class="stat">
<span class="stat-label"><?= htmlspecialchars($t('intro.stats.area'), ENT_QUOTES) ?></span>
<span class="stat-value">196,5 m²</span>
</div>
<div>
<div class="istat-val">2 Stpl.</div>
<div class="istat-label">Garage</div>
<div class="stat">
<span class="stat-label"><?= htmlspecialchars($t('intro.stats.terrace'), ENT_QUOTES) ?></span>
<span class="stat-value">35,8 m²</span>
</div>
<div class="stat">
<span class="stat-label"><?= htmlspecialchars($t('intro.stats.garage'), ENT_QUOTES) ?></span>
<span class="stat-value">2 PKW</span>
</div>
</div>
<div class="intro-img" data-animate>
<picture>
<source srcset="/bilder/wohnzimmer2.webp" type="image/webp">
<img src="/bilder/wohnzimmer2.png" alt="Wohnzimmer" loading="lazy" />
</picture>
<div class="intro-img-badge">Wohnzimmer · 42,6 </div>
<span class="intro-badge"><?= htmlspecialchars($t('intro.badge'), ENT_QUOTES) ?></span>
</aside>
</div>
</section>
<section id="galerie" class="gallery-section" aria-label="Fotogalerie">
<div class="gallery-header">
<div>
<div class="section-eyebrow">Fotogalerie</div>
<h2>Einblicke ins Haus</h2>
</div>
<section class="gallery-section" id="galerie" aria-label="<?= htmlspecialchars($t('gallery.aria'), ENT_QUOTES) ?>">
<div class="section-head">
<span class="section-eyebrow"><?= htmlspecialchars($t('gallery.eyebrow'), ENT_QUOTES) ?></span>
<h2><?= htmlspecialchars($t('gallery.h2'), ENT_QUOTES) ?></h2>
</div>
<div class="masonry-grid">
<div class="grid-sizer"></div>
<div class="grid-item" data-img="/bilder/Außenansicht-2.webp" role="button" tabindex="0" aria-label="Außenansicht Großansicht öffnen">
<picture>
<source srcset="/bilder/Außenansicht-2-small.webp" type="image/webp">
<img src="/bilder/Außenansicht-2-small.png" alt="Außenansicht des Einfamilienhauses" loading="lazy" />
</picture>
<span class="grid-item-label">Außenansicht</span>
</div>
<div class="grid-item" data-img="/bilder/wohnzimmer2.webp" role="button" tabindex="0" aria-label="Wohnzimmer Großansicht öffnen">
<picture>
<source srcset="/bilder/wohnzimmer2-small.webp" type="image/webp">
<img src="/bilder/wohnzimmer2-small.png" alt="Wohnzimmer mit 42,6 m² Wohnfläche" loading="lazy" />
</picture>
<span class="grid-item-label">Wohnzimmer · 42,6 </span>
</div>
<div class="grid-item" data-img="/bilder/Küche 1.webp" role="button" tabindex="0" aria-label="Küche Großansicht öffnen">
<picture>
<source srcset="/bilder/Küche 1-small.webp" type="image/webp">
<img src="/bilder/Küche 1.jpg" alt="Küche mit 18,4 m²" loading="lazy" />
</picture>
<span class="grid-item-label">Küche · 18,4 </span>
</div>
<div class="grid-item" data-img="/bilder/schlafzimmer.webp" role="button" tabindex="0" aria-label="Schlafzimmer Großansicht öffnen">
<picture>
<source srcset="/bilder/schlafzimmer-small.webp" type="image/webp">
<img src="/bilder/schlafzimmer-small.png" alt="Schlafzimmer mit 18 m²" loading="lazy" />
</picture>
<span class="grid-item-label">Schlafzimmer · 18 </span>
</div>
<div class="grid-item" data-img="/bilder/Bad.webp" role="button" tabindex="0" aria-label="Badezimmer Großansicht öffnen">
<picture>
<source srcset="/bilder/Bad-small.webp" type="image/webp">
<img src="/bilder/Bad.jpg" alt="Badezimmer mit 9,8 m²" loading="lazy" />
</picture>
<span class="grid-item-label">Badezimmer · 9,8 </span>
</div>
<div class="grid-item" data-img="/bilder/Kinderzimmer.webp" role="button" tabindex="0" aria-label="Kinderzimmer 1 Großansicht öffnen">
<picture>
<source srcset="/bilder/Kinderzimmer-small.webp" type="image/webp">
<img src="/bilder/Kinderzimmer-small.png" alt="Kinderzimmer 1 mit 21,7 m²" loading="lazy" />
</picture>
<span class="grid-item-label">Kinderzimmer 1 · 21,7 </span>
</div>
<div class="grid-item" data-img="/bilder/Kinderzimmer 2.webp" role="button" tabindex="0" aria-label="Kinderzimmer 2 Großansicht öffnen">
<picture>
<source srcset="/bilder/Kinderzimmer 2-small.webp" type="image/webp">
<img src="/bilder/Kinderzimmer 2-small.png" alt="Kinderzimmer 2 mit 15,7 m²" loading="lazy" />
</picture>
<span class="grid-item-label">Kinderzimmer 2 · 15,7 </span>
</div>
<div class="grid-item" data-img="/bilder/kinderzimmer 2 2.webp" role="button" tabindex="0" aria-label="Kinderzimmer Detail Großansicht öffnen">
<picture>
<source srcset="/bilder/kinderzimmer 2 2-small.webp" type="image/webp">
<img src="/bilder/kinderzimmer 2 2-small.png" alt="Detailansicht Kinderzimmer" loading="lazy" />
</picture>
<span class="grid-item-label">Kinderzimmer Detail</span>
</div>
<div class="grid-item" data-img="/bilder/Kinderzimmer 3.webp" role="button" tabindex="0" aria-label="Gästezimmer Großansicht öffnen">
<picture>
<source srcset="/bilder/Kinderzimmer 3-small.webp" type="image/webp">
<img src="/bilder/Kinderzimmer 3-small.png" alt="Gästezimmer mit 11,5 m²" loading="lazy" />
</picture>
<span class="grid-item-label">Gästezimmer · 11,5 </span>
</div>
<div class="grid-item" data-img="/bilder/Bad-2.webp" role="button" tabindex="0" aria-label="Zweites Bad Großansicht öffnen">
<picture>
<source srcset="/bilder/Bad-2-small.webp" type="image/webp">
<img src="/bilder/Bad-2-small.jpg" alt="Zweites Badezimmer im Haus" loading="lazy" />
</picture>
<span class="grid-item-label">Wohnbereich</span>
</div>
<div class="grid-item" data-img="/bilder/Bad-3.webp" role="button" tabindex="0" aria-label="Drittes Bad Großansicht öffnen">
<picture>
<source srcset="/bilder/Bad-3-small.webp" type="image/webp">
<img src="/bilder/Bad-3-small.jpg" alt="Drittes Badezimmer im Haus" loading="lazy" />
</picture>
<span class="grid-item-label">Wohnbereich Detail</span>
</div>
<div class="grid-item" data-img="/bilder/Bad-4.webp" role="button" tabindex="0" aria-label="Wohnbereich Detail Großansicht öffnen">
<picture>
<source srcset="/bilder/Bad-4-small.webp" type="image/webp">
<img src="/bilder/Bad-4-small.jpg" alt="Wohnbereich Detail 3" loading="lazy" />
</picture>
<span class="grid-item-label">Hausansicht</span>
</div>
<?php foreach ($gridItems as $item): ?>
<button type="button" class="grid-item"
data-img="<?= htmlspecialchars($item['img'], ENT_QUOTES) ?>"
aria-label="<?= htmlspecialchars($t($item['key']) . $t('gallery.zoom'), ENT_QUOTES) ?>">
<img src="/<?= htmlspecialchars($item['img'], ENT_QUOTES) ?>" alt="<?= htmlspecialchars($t($item['alt']), ENT_QUOTES) ?>" loading="lazy" decoding="async">
<span class="grid-item-label"><?= htmlspecialchars($t($item['key']), ENT_QUOTES) ?></span>
</button>
<?php endforeach; ?>
</div>
</section>
<section class="floors-section" id="grundriss">
<div class="section-eyebrow">Raumaufteilung</div>
<h2>Großzügig auf allen Etagen</h2>
<div class="floor-accordion">
<div class="floor-item">
<div class="floor-header" role="button" tabindex="0" aria-expanded="false" aria-controls="floor-body-0" id="floor-title-0">
<span class="floor-title">Erdgeschoss</span>
<div class="floor-size">
<span>99,5 </span>
<div class="floor-icon">+</div>
</div>
</div>
<div class="floor-body" id="floor-body-0" role="region" aria-labelledby="floor-title-0">
<div class="floor-rooms-grid">
<div class="room-chip">Flur<span class="room-chip-area">20,1 </span></div>
<div class="room-chip">WC<span class="room-chip-area">0,8 </span></div>
<div class="room-chip">Garage / Partykeller<span class="room-chip-area">42,6 </span></div>
<div class="room-chip">Abstellraum 1<span class="room-chip-area">9,9 </span></div>
<div class="room-chip">Abstellraum 2<span class="room-chip-area">7,8 </span></div>
<div class="room-chip">Heizungskeller<span class="room-chip-area">18,3 </span></div>
</div>
<div class="floor-plan floor-plan-multi">
<picture>
<source srcset="/bilder/grundrisse/EG-small.webp" type="image/webp">
<img src="/bilder/grundrisse/EG-small.jpg" alt="Grundriss Erdgeschoss" loading="lazy" data-img="/bilder/grundrisse/EG.webp" />
</picture>
<picture>
<source srcset="/bilder/grundrisse/EG 3D-small.webp" type="image/webp">
<img src="/bilder/grundrisse/EG 3D-small.jpg" alt="Grundriss Erdgeschoss" loading="lazy" data-img="/bilder/grundrisse/EG 3D.webp" />
</picture>
</div>
</div>
</div>
<div class="floor-item">
<div class="floor-header" role="button" tabindex="0" aria-expanded="false" aria-controls="floor-body-1" id="floor-title-1">
<span class="floor-title">1. Obergeschoss</span>
<div class="floor-size">
<span>120,4 </span>
<div class="floor-icon">+</div>
</div>
</div>
<div class="floor-body" id="floor-body-1" role="region" aria-labelledby="floor-title-1">
<div class="floor-rooms-grid">
<div class="room-chip">Flur<span class="room-chip-area">20,1 </span></div>
<div class="room-chip">Wohnzimmer<span class="room-chip-area">42,6 </span></div>
<div class="room-chip">Gästezimmer<span class="room-chip-area">11,5 </span></div>
<div class="room-chip">Badezimmer<span class="room-chip-area">9,8 </span></div>
<div class="room-chip">Küche<span class="room-chip-area">18,4 </span></div>
<div class="room-chip">Schlafzimmer<span class="room-chip-area">18,0 </span></div>
</div>
<div class="floor-plan floor-plan-multi">
<picture>
<source srcset="/bilder/grundrisse/OG 1 2-small.webp" type="image/webp">
<img src="/bilder/grundrisse/OG 1 2-small.jpg" alt="Grundriss 1. Obergeschoss" loading="lazy" data-img="/bilder/grundrisse/OG 1 2.webp" />
</picture>
<picture>
<source srcset="/bilder/grundrisse/OG 1 3D-small.webp" type="image/webp">
<img src="/bilder/grundrisse/OG 1 3D-small.jpg" alt="Grundriss 1. Obergeschoss" loading="lazy" data-img="/bilder/grundrisse/OG 1 3D.webp" />
</picture>
</div>
</div>
</div>
<div class="floor-item">
<div class="floor-header" role="button" tabindex="0" aria-expanded="false" aria-controls="floor-body-2" id="floor-title-2">
<span class="floor-title">2. Obergeschoss</span>
<div class="floor-size">
<span>68 </span>
<div class="floor-icon">+</div>
</div>
</div>
<div class="floor-body" id="floor-body-2" role="region" aria-labelledby="floor-title-2">
<div class="floor-rooms-grid">
<div class="room-chip">Flur<span class="room-chip-area">13,9 </span></div>
<div class="room-chip">Kinderzimmer 1<span class="room-chip-area">21,7 </span></div>
<div class="room-chip">Kinderzimmer 2<span class="room-chip-area">15,7 </span></div>
<div class="room-chip">Spielzimmer<span class="room-chip-area">6,3 </span></div>
<div class="room-chip">Ankleidezimmer<span class="room-chip-area">1,4 </span></div>
<div class="room-chip">Dachterrasse<span class="room-chip-area">9,0 </span> <small>(25% von 35,8 )</small></div>
</div>
<div class="floor-plan floor-plan-multi">
<picture>
<source srcset="/bilder/grundrisse/OG 2 grundriss-small.webp" type="image/webp">
<img src="/bilder/grundrisse/OG 2 grundriss-small.jpg" alt="Grundriss 2. Obergeschoss" loading="lazy" data-img="/bilder/grundrisse/OG 2 grundriss.webp" />
</picture>
<picture>
<source srcset="/bilder/grundrisse/OG 2 3D-small.webp" type="image/webp">
<img src="/bilder/grundrisse/OG 2 3D-small.jpg" alt="Grundriss 2. Obergeschoss" loading="lazy" data-img="/bilder/grundrisse/OG 2 3D.webp" />
</picture>
</div>
</div>
</div>
<div class="floor-item">
<div class="floor-header" role="button" tabindex="0" aria-expanded="false" aria-controls="floor-body-3" id="floor-title-3">
<span class="floor-title">Dachboden</span>
<div class="floor-size">
<span>94 Nutzfläche</span>
<div class="floor-icon">+</div>
</div>
</div>
<div class="floor-body" id="floor-body-3" role="region" aria-labelledby="floor-title-3">
<div class="floor-rooms-grid">
<div class="room-chip">Dachboden unten (ungeheizt)<span class="room-chip-area">52 </span></div>
<div class="room-chip">Dachboden Mitte (ungeheizt)<span class="room-chip-area">31 </span></div>
<div class="room-chip">Dachboden oben (ungeheizt)<span class="room-chip-area">11 </span></div>
</div>
<div class="floor-plan floor-plan-multi">
<picture>
<source srcset="/bilder/grundrisse/Dachboden unten 2-small.webp" type="image/webp">
<img src="/bilder/grundrisse/Dachboden unten 2-small.jpg" alt="Grundriss Dachboden" loading="lazy" data-img="/bilder/grundrisse/Dachboden unten 2.webp" />
</picture>
<picture>
<source srcset="/bilder/grundrisse/Dachboden unten-small.webp" type="image/webp">
<img src="/bilder/grundrisse/Dachboden unten-small.jpg" alt="Grundriss Dachboden" loading="lazy" data-img="/bilder/grundrisse/Dachboden unten.webp" />
</picture>
</div>
<section class="floors-section" id="grundriss" aria-label="<?= htmlspecialchars($t('floors.eyebrow'), ENT_QUOTES) ?>">
<div class="section-head">
<span class="section-eyebrow"><?= htmlspecialchars($t('floors.eyebrow'), ENT_QUOTES) ?></span>
<h2><?= htmlspecialchars($t('floors.h2'), ENT_QUOTES) ?></h2>
</div>
<?php
$floorImageMap = [
'eg' => 'bilder/grundrisse/EG.png',
'og1' => 'bilder/grundrisse/OG 1 2.png',
'og2' => 'bilder/grundrisse/OG 2 grundriss.png',
'attic' => 'bilder/grundrisse/Dachboden unten.png',
];
$floors = [
['id' => 'eg', 'titleKey' => 'floors.eg.title', 'areaKey' => 'floors.eg.area', 'altKey' => 'floors.alt.eg',
'rooms' => [
['key' => 'floors.room.hall', 'size' => '21,0'],
['key' => 'floors.room.wc', 'size' => '1,7'],
['key' => 'floors.room.garage', 'size' => '23,4'],
['key' => 'floors.room.storage1', 'size' => '5,5'],
['key' => 'floors.room.heating', 'size' => '11,2'],
['key' => 'floors.room.storage2', 'size' => '6,4'],
]],
['id' => 'og1', 'titleKey' => 'floors.og1.title', 'areaKey' => 'floors.og1.area', 'altKey' => 'floors.alt.og1',
'rooms' => [
['key' => 'floors.room.living', 'size' => '42,6'],
['key' => 'floors.room.kitchen', 'size' => '18,4'],
['key' => 'floors.room.guest', 'size' => '11,5'],
['key' => 'floors.room.bath', 'size' => '9,8'],
['key' => 'floors.room.storage1','size' => '3,4'],
['key' => 'floors.room.heating', 'size' => '8,0'],
]],
['id' => 'og2', 'titleKey' => 'floors.og2.title', 'areaKey' => 'floors.og2.area', 'altKey' => 'floors.alt.og2',
'rooms' => [
['key' => 'floors.room.bedroom', 'size' => '18,0'],
['key' => 'floors.room.kid1', 'size' => '21,7'],
['key' => 'floors.room.kid2', 'size' => '15,7'],
['key' => 'floors.room.bath', 'size' => '6,4'],
]],
['id' => 'attic','titleKey' => 'floors.attic.title', 'areaKey' => 'floors.attic.area', 'altKey' => 'floors.alt.attic',
'rooms' => [
['key' => 'floors.room.attic_low', 'size' => ''],
['key' => 'floors.room.attic_mid', 'size' => ''],
['key' => 'floors.room.attic_high', 'size' => ''],
]],
];
?>
<div class="floors-accordion">
<?php foreach ($floors as $floor): ?>
<details class="floor-item" id="floor-<?= htmlspecialchars($floor['id'], ENT_QUOTES) ?>">
<summary class="floor-header">
<span class="floor-title"><?= htmlspecialchars($t($floor['titleKey']), ENT_QUOTES) ?></span>
<span class="floor-area"><?= htmlspecialchars($t($floor['areaKey']), ENT_QUOTES) ?></span>
</summary>
<div class="floor-body">
<img src="/<?= htmlspecialchars($floorImageMap[$floor['id']] ?? 'bilder/grundrisse/EG.png', ENT_QUOTES) ?>"
alt="<?= htmlspecialchars($t($floor['altKey']), ENT_QUOTES) ?>"
loading="lazy" decoding="async"
class="floor-plan-img">
<ul class="room-list">
<?php foreach ($floor['rooms'] as $room): ?>
<li>
<span class="room-name"><?= htmlspecialchars($t($room['key']), ENT_QUOTES) ?></span>
<?php if ($room['size'] !== ''): ?>
<span class="room-size"><?= htmlspecialchars($room['size'], ENT_QUOTES) ?> m²</span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
</div>
</details>
<?php endforeach; ?>
</div>
</section>
<section class="pricing-section" id="miete" aria-label="Mietkonditionen">
<div class="pricing-inner">
<div class="section-eyebrow">Mietkonditionen</div>
<h2>Transparente Preisgestaltung</h2>
<div class="price-cards">
<section class="pricing-section" id="miete" aria-label="<?= htmlspecialchars($t('rent.aria'), ENT_QUOTES) ?>">
<div class="section-head">
<span class="section-eyebrow"><?= htmlspecialchars($t('rent.eyebrow'), ENT_QUOTES) ?></span>
<h2><?= htmlspecialchars($t('rent.h2'), ENT_QUOTES) ?></h2>
</div>
<div class="pricing-grid">
<div class="price-card">
<div class="pc-label">Kaltmiete</div>
<div class="pc-val">1.300 </div>
<div class="pc-sub">pro Monat</div>
</div>
<div class="price-card highlight">
<div class="pc-label">Gesamtmiete warm</div>
<div class="pc-val">1.600 </div>
<div class="pc-sub">inkl. 300 Nebenkosten</div>
<span class="price-label"><?= htmlspecialchars($t('rent.cold'), ENT_QUOTES) ?></span>
<span class="price-value">1.300 €</span>
<span class="price-unit"><?= htmlspecialchars($t('rent.per_month'), ENT_QUOTES) ?></span>
</div>
<div class="price-card">
<div class="pc-label">Kaution</div>
<div class="pc-val">2.600 </div>
<div class="pc-sub">2 Nettokaltmieten</div>
</div>
</div>
<div class="price-note">
<div class="pn-item">
<strong>Verfügbarkeit</strong>
Ab sofort · unbefristete Laufzeit
</div>
<div class="pn-item">
<strong>Nebenkosten</strong>
Vorauszahlung 300 /Monat, jährliche Abrechnung
</div>
<div class="pn-item">
<strong>Energieausweis</strong>
Wird bei Mietbeginn übergeben · Erdgasheizung
</div>
<div class="pn-item">
<strong>Haustiere</strong>
Auf Anfrage
</div>
<span class="price-label"><?= htmlspecialchars($t('rent.warm'), ENT_QUOTES) ?></span>
<span class="price-value">1.600 €</span>
<span class="price-unit"><?= htmlspecialchars($t('rent.warm_includes'), ENT_QUOTES) ?></span>
</div>
<div class="price-card">
<span class="price-label"><?= htmlspecialchars($t('rent.deposit'), ENT_QUOTES) ?></span>
<span class="price-value">2.600 €</span>
<span class="price-unit"><?= htmlspecialchars($t('rent.deposit_months'), ENT_QUOTES) ?></span>
</div>
</div>
<dl class="rent-notes">
<dt><?= htmlspecialchars($t('rent.note.available'), ENT_QUOTES) ?></dt>
<dd><?= htmlspecialchars($t('rent.note.available_val'), ENT_QUOTES) ?></dd>
<dt><?= htmlspecialchars($t('rent.note.costs'), ENT_QUOTES) ?></dt>
<dd><?= htmlspecialchars($t('rent.note.costs_val'), ENT_QUOTES) ?></dd>
<dt><?= htmlspecialchars($t('rent.note.energy'), ENT_QUOTES) ?></dt>
<dd><?= htmlspecialchars($t('rent.note.energy_val'), ENT_QUOTES) ?></dd>
<dt><?= htmlspecialchars($t('rent.note.pets'), ENT_QUOTES) ?></dt>
<dd><?= htmlspecialchars($t('rent.note.pets_val'), ENT_QUOTES) ?></dd>
</dl>
</section>
<section class="lage-section" id="lage">
<div class="section-eyebrow">Standort</div>
<h2>Zentral und ruhig zugleich</h2>
<section class="lage-section" id="lage" aria-label="<?= htmlspecialchars($t('loc.eyebrow'), ENT_QUOTES) ?>">
<div class="section-head">
<span class="section-eyebrow"><?= htmlspecialchars($t('loc.eyebrow'), ENT_QUOTES) ?></span>
<h2><?= htmlspecialchars($t('loc.h2'), ENT_QUOTES) ?></h2>
</div>
<div class="lage-grid">
<div class="lage-item">
<div class="lage-icon">🛒</div>
<div>
<div class="lage-title">Einkaufen & Versorgung</div>
<div class="lage-desc">Supermärkte, Ärzte, Apotheken und Schulen sind fußläufig erreichbar</div>
</div>
</div>
<div class="lage-item">
<div class="lage-icon">🚌</div>
<div>
<div class="lage-title">Öffentlicher Nahverkehr</div>
<div class="lage-desc">Zentrale Bushaltestelle ca. 200 m entfernt direkte Verbindungen in die Region</div>
</div>
</div>
<div class="lage-item">
<div class="lage-icon">🏛</div>
<div>
<div class="lage-title">Innenstadt Schleusingen</div>
<div class="lage-desc">Wochenmarkt und Stadtmitte nur ca. 500 m entfernt</div>
</div>
</div>
<div class="lage-item">
<div class="lage-icon">📍</div>
<div>
<div class="lage-title">Genaue Adresse</div>
<div class="lage-desc">Schleusinger Bahnhofstraße 10<br />98553 Schleusingen, Thüringen</div>
</div>
</div>
<ul class="lage-features">
<li>
<span class="lage-feature-title"><?= htmlspecialchars($t('loc.shopping'), ENT_QUOTES) ?></span>
<span class="lage-feature-desc"><?= htmlspecialchars($t('loc.shopping_desc'), ENT_QUOTES) ?></span>
</li>
<li>
<span class="lage-feature-title"><?= htmlspecialchars($t('loc.transport'), ENT_QUOTES) ?></span>
<span class="lage-feature-desc"><?= htmlspecialchars($t('loc.transport_desc'), ENT_QUOTES) ?></span>
</li>
<li>
<span class="lage-feature-title"><?= htmlspecialchars($t('loc.center'), ENT_QUOTES) ?></span>
<span class="lage-feature-desc"><?= htmlspecialchars($t('loc.center_desc'), ENT_QUOTES) ?></span>
</li>
</ul>
</div>
<div class="lage-map-wrapper">
<iframe
src="https://maps.google.com/maps?q=50.5090045,10.7473859&t=&z=16&ie=UTF8&iwloc=&output=embed"
width="100%" height="450" style="border: 0" allowfullscreen="" loading="lazy"
referrerpolicy="no-referrer-when-downgrade"
title="Standort Bahnhofstraße 10, Schleusingen"
></iframe>
title="<?= htmlspecialchars($t('loc.map_title'), ENT_QUOTES) ?>"
src="https://www.openstreetmap.org/export/embed.html?bbox=10.7535%2C50.5095%2C10.7705%2C50.5185&amp;layer=mapnik&amp;marker=50.5140%2C10.7620"
loading="lazy" referrerpolicy="no-referrer-when-downgrade"></iframe>
<p class="lage-address">
<strong><?= htmlspecialchars($t('loc.address'), ENT_QUOTES) ?>:</strong><br>
<?= /* address HTML is XSS-safe — composed of trusted translations */ $t('loc.address_val') ?>
</p>
</div>
</section>
<section class="contact-section" id="kontakt" aria-label="Kontaktformular">
<div class="contact-inner">
<div class="section-eyebrow">Kontakt</div>
<h2>Interesse?<br /><em>Schreiben Sie uns.</em></h2>
<p>
Wir freuen uns über Ihre Anfrage und melden uns innerhalb von 24 Stunden.
Besichtigungstermine sind nach Absprache möglich. Bitte geben Sie bei Ihrer Anfrage ein
paar Terminvorschläge an.
</p>
<div class="contact-form">
<?php if ($formSuccess): ?>
<div id="form-result" class="form-success" style="display: block">
<p>Vielen Dank für Ihre Anfrage!</p>
<br />
<small>Wir haben Ihre Nachricht erhalten und melden uns innerhalb von 24 Stunden bei Ihnen.</small>
<section class="contact-section" id="kontakt" aria-label="<?= htmlspecialchars($t('contact.aria'), ENT_QUOTES) ?>">
<div class="section-head">
<span class="section-eyebrow"><?= htmlspecialchars($t('contact.eyebrow'), ENT_QUOTES) ?></span>
<h2><?= htmlspecialchars($t('contact.h2'), ENT_QUOTES) ?> <em><?= htmlspecialchars($t('contact.h2_em'), ENT_QUOTES) ?></em></h2>
<p class="contact-intro"><?= htmlspecialchars($t('contact.intro'), ENT_QUOTES) ?></p>
</div>
<?php else: ?>
<?php if (!empty($formErrors)): ?>
<div id="form-errors" class="form-errors">
<div id="form-result" class="form-result" role="status" aria-live="polite">
<?php if ($formSuccess): ?>
<div class="form-success">
<strong><?= htmlspecialchars($t('contact.success'), ENT_QUOTES) ?></strong>
<p><?= htmlspecialchars($t('contact.success_sub'), ENT_QUOTES) ?></p>
</div>
<?php elseif (!empty($formErrors)): ?>
<div class="form-errors" role="alert">
<ul>
<?php foreach ($formErrors as $error): ?>
<li><?= $escapeContactValue($error) ?></li>
<?php foreach ($formErrors as $errKey): ?>
<li><?= htmlspecialchars($t($errKey), ENT_QUOTES) ?></li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
<form id="contactForm" method="post">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '') ?>" />
</div>
<form class="contact-form" method="post" action="/#kontakt" novalidate>
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'] ?? '', ENT_QUOTES) ?>">
<input type="hidden" name="form_time" value="<?= htmlspecialchars((string) time(), ENT_QUOTES) ?>">
<div class="hp-field" aria-hidden="true">
<label for="website-hp"><?= htmlspecialchars($t('contact.hp_label'), ENT_QUOTES) ?></label>
<input type="text" id="website-hp" name="website" tabindex="-1" autocomplete="off">
</div>
<div class="form-row">
<div class="form-field">
<label for="fname">Vorname</label>
<input type="text" id="fname" name="fname" placeholder="Max" required value="<?= $escapeContactValue($formData['fname']) ?>" />
<label for="fname"><?= htmlspecialchars($t('contact.fname'), ENT_QUOTES) ?></label>
<input type="text" id="fname" name="fname" required maxlength="80" autocomplete="given-name"
value="<?= $escapeContactValue($formData['fname'] ?? '') ?>"
<?= !empty($formFieldErrors['fname']) ? 'aria-invalid="true" aria-describedby="err-fname"' : '' ?>>
<?php if (!empty($formFieldErrors['fname'])): ?>
<p id="err-fname" class="form-field-error"><?= htmlspecialchars($t($formFieldErrors['fname'][0]), ENT_QUOTES) ?></p>
<?php endif; ?>
</div>
<div class="form-field">
<label for="lname">Nachname</label>
<input type="text" id="lname" name="lname" placeholder="Mustermann" required value="<?= $escapeContactValue($formData['lname']) ?>" />
<label for="lname"><?= htmlspecialchars($t('contact.lname'), ENT_QUOTES) ?></label>
<input type="text" id="lname" name="lname" required maxlength="80" autocomplete="family-name"
value="<?= $escapeContactValue($formData['lname'] ?? '') ?>"
<?= !empty($formFieldErrors['lname']) ? 'aria-invalid="true" aria-describedby="err-lname"' : '' ?>>
<?php if (!empty($formFieldErrors['lname'])): ?>
<p id="err-lname" class="form-field-error"><?= htmlspecialchars($t($formFieldErrors['lname'][0]), ENT_QUOTES) ?></p>
<?php endif; ?>
</div>
</div>
<div class="form-row">
<div class="form-field">
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" placeholder="max@beispiel.de" required value="<?= $escapeContactValue($formData['email']) ?>" />
<label for="email"><?= htmlspecialchars($t('contact.email'), ENT_QUOTES) ?></label>
<input type="email" id="email" name="email" required maxlength="120" autocomplete="email"
value="<?= $escapeContactValue($formData['email'] ?? '') ?>"
<?= !empty($formFieldErrors['email']) ? 'aria-invalid="true" aria-describedby="err-email"' : '' ?>>
<?php if (!empty($formFieldErrors['email'])): ?>
<p id="err-email" class="form-field-error"><?= htmlspecialchars($t($formFieldErrors['email'][0]), ENT_QUOTES) ?></p>
<?php endif; ?>
</div>
<div class="form-field">
<label for="phone">Telefon</label>
<input type="tel" id="phone" name="phone" placeholder="+49 ..." value="<?= $escapeContactValue($formData['phone']) ?>" />
<label for="phone"><?= htmlspecialchars($t('contact.phone'), ENT_QUOTES) ?></label>
<input type="tel" id="phone" name="phone" maxlength="40" autocomplete="tel"
value="<?= $escapeContactValue($formData['phone'] ?? '') ?>">
</div>
</div>
<div class="form-row">
<div class="form-field full">
<label for="interest">Anliegen</label>
<select id="interest" name="interest">
<div class="form-field">
<label for="interest"><?= htmlspecialchars($t('contact.interest'), ENT_QUOTES) ?></label>
<select id="interest" name="interest" required>
<?php
$interestOptions = ['Besichtigung anfragen', 'Allgemeine Informationen', 'Mietbewerbung einreichen'];
foreach ($interestOptions as $opt):
$selected = ($formData['interest'] === $opt) ? ' selected' : '';
?>
<option<?= $selected ?>><?= $escapeContactValue($opt) ?></option>
$currentInterest = $formData['interest'] ?? 'visit';
$interestLabels = [
'visit' => 'contact.interest_visit',
'info' => 'contact.interest_info',
'apply' => 'contact.interest_apply',
];
foreach ($interestLabels as $value => $labelKey): ?>
<option value="<?= htmlspecialchars($value, ENT_QUOTES) ?>"
<?= $currentInterest === $value ? 'selected' : '' ?>>
<?= htmlspecialchars($t($labelKey), ENT_QUOTES) ?>
</option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-row">
<div class="form-field full">
<label for="message">Nachricht</label>
<textarea id="message" name="message" rows="4" placeholder="Ihre Nachricht ..." required><?= $escapeContactValue($formData['message']) ?></textarea>
</div>
</div>
<div class="hp-field" aria-hidden="true">
<label for="website">Website</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" />
</div>
<input type="hidden" name="form_time" value="<?= time() ?>" />
<button type="submit" class="btn-submit">Anfrage absenden</button>
</form>
<div class="form-field">
<label for="message"><?= htmlspecialchars($t('contact.message'), ENT_QUOTES) ?></label>
<textarea id="message" name="message" required rows="6" maxlength="2000"
placeholder="<?= htmlspecialchars($t('contact.message'), ENT_QUOTES) ?>"
<?= !empty($formFieldErrors['message']) ? 'aria-invalid="true" aria-describedby="err-message"' : ''
?>><?= $escapeContactValue($formData['message'] ?? '') ?></textarea>
<?php if (!empty($formFieldErrors['message'])): ?>
<p id="err-message" class="form-field-error"><?= htmlspecialchars($t($formFieldErrors['message'][0]), ENT_QUOTES) ?></p>
<?php endif; ?>
</div>
<div class="contact-details">
<p>Oder schreiben Sie uns direkt: <a href="mailto:mki@kies-media.de">mki@kies-media.de</a></p>
</div>
</div>
<button type="submit" class="form-submit"><?= htmlspecialchars($t('contact.submit'), ENT_QUOTES) ?></button>
<p class="contact-direct"><?= htmlspecialchars($t('contact.direct'), ENT_QUOTES) ?>
<a href="mailto:mki@kies-media.de">mki@kies-media.de</a>
</p>
</form>
</section>
</main>
<footer role="contentinfo">
<div class="footer-logo">Bahnhofstraße 10 · Schleusingen</div>
<div class="footer-links">
<a href="/impressum">Impressum</a>
<a href="/datenschutz">Datenschutz</a>
</div>
</footer>
<div class="lightbox" id="lightbox" role="dialog" aria-modal="true" aria-label="Bildansicht">
<button class="lightbox-close" id="lightboxClose" aria-label="Bildansicht schließen">&times;</button>
<img src="" id="lightboxImg" alt="" />
</div>

View File

@@ -1,17 +1,18 @@
<nav id="navbar" class="scrolled">
<div class="nav-logo">Bahnhofstraße 10</div>
<ul class="nav-links">
<li><a href="/#galerie">Galerie</a></li>
<li><a href="/#grundriss">Grundriss</a></li>
<li><a href="/#miete">Miete</a></li>
<li><a href="/#lage">Lage</a></li>
</ul>
<a href="/#kontakt" class="nav-cta" style="text-decoration:none;">Jetzt anfragen</a>
</nav>
<?php
declare(strict_types=1);
/**
* Impressum — page body only (nav/footer/lightbox live in layouts/main.php).
* Legal body stays in German by design (§ 5 TMG requires German).
*
* @var string $locale
* @var callable(string,array,string=):string $t
*/
?>
<main class="legal-page">
<div class="section-eyebrow">Pflichtangaben</div>
<h1>Impressum</h1>
<div class="section-eyebrow"><?= htmlspecialchars($t('legal.imprint_eyebrow'), ENT_QUOTES) ?></div>
<h1><?= htmlspecialchars($t('legal.imprint_h1'), ENT_QUOTES) ?></h1>
<h2>Angaben gemäß § 5 TMG</h2>
<address>
@@ -73,13 +74,5 @@
Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte umgehend entfernen.
</p>
<a href="/" class="legal-back"> Zurück zum Objekt</a>
<a href="/" class="legal-back"><?= htmlspecialchars($t('legal.back'), ENT_QUOTES) ?></a>
</main>
<footer>
<div class="footer-logo">Bahnhofstraße 10 · Schleusingen</div>
<div class="footer-links">
<a href="/impressum">Impressum</a>
<a href="/datenschutz">Datenschutz</a>
</div>
</footer>

View File

@@ -1,21 +1,64 @@
<?php
declare(strict_types=1);
/**
* @var string $content Page body (rendered by the controller)
* @var string $locale Current locale code (e.g. 'de')
* @var callable $t Translation helper
* @var callable $locale_switcher Returns the locale switcher HTML
* @var string|null $pageTitle Optional page title override
* @var string|null $pageDescription Optional meta description override
* @var string|null $canonical Optional canonical URL override
* @var string|null $robots Optional robots meta override
* @var array<string,string>|null $openGraph OG meta map: ogTitle, ogDescription, ogImage, ogUrl
* @var string|null $structuredData JSON-LD blob
* @var string|null $extraCss Optional inline CSS
*/
use App\Core\I18n;
use App\Core\Locale;
$currentPath = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
$ogLocale = Locale::toOgLocale($locale);
$title = $pageTitle ?? I18n::t('site.title', [], $locale);
$description = $pageDescription ?? I18n::t('site.description', [], $locale);
$canonicalBase = I18n::t('site.canonical_base', [], $locale);
$canonical = $canonical ?? $canonicalBase . ($currentPath === '/' ? '/' : $currentPath);
$siteName = I18n::t('site.name', [], $locale);
$ogTitle = $openGraph['ogTitle'] ?? $title;
$ogDescription = $openGraph['ogDescription'] ?? $description;
$ogImage = $openGraph['ogImage'] ?? 'https://haus-schleusingen.de/bilder/Außenansicht-2.png';
$ogUrl = $openGraph['ogUrl'] ?? $canonical;
$hreflangs = Locale::hreflangAlternates($currentPath === '/' ? '/' : $currentPath, $canonicalBase);
$homeUrl = $canonicalBase . '/';
$isHome = $currentPath === '/' || $currentPath === '';
$isImpr = $currentPath === '/impressum';
$isPriv = $currentPath === '/datenschutz';
$navItems = [
['href' => '/#galerie', 'label' => 'nav.gallery', 'active' => false],
['href' => '/#grundriss','label' => 'nav.layout', 'active' => false],
['href' => '/#miete', 'label' => 'nav.rent', 'active' => false],
['href' => '/#lage', 'label' => 'nav.location', 'active' => false],
];
?>
<!doctype html>
<html lang="de">
<html lang="<?= htmlspecialchars($locale, ENT_QUOTES) ?>">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<?php if (!isset($pageTitle)) $pageTitle = 'Einfamilienhaus mieten Schleusingen | 227 m², 6 Zimmer | 1.300 € Kaltmiete'; ?>
<title><?= htmlspecialchars($pageTitle) ?></title>
<?php if (isset($pageDescription)): ?>
<meta name="description" content="<?= htmlspecialchars($pageDescription) ?>" />
<?php else: ?>
<meta name="description" content="Einfamilienhaus zur Langzeitmiete in Schleusingen: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €. Bahnhofstraße 10, 98553 Schleusingen. Ab sofort verfügbar." />
<?php endif; ?>
<title><?= htmlspecialchars($title) ?></title>
<meta name="description" content="<?= htmlspecialchars($description) ?>" />
<?php if (isset($robots)): ?>
<meta name="robots" content="<?= htmlspecialchars($robots) ?>" />
<?php endif; ?>
<?php if (isset($canonical)): ?>
<link rel="canonical" href="<?= htmlspecialchars($canonical) ?>" />
<?php endif; ?>
<?php foreach ($hreflangs as $alt): ?>
<link rel="alternate" hreflang="<?= htmlspecialchars($alt['hreflang']) ?>" href="<?= htmlspecialchars($alt['href']) ?>" />
<?php endforeach; ?>
<link rel="icon" type="image/png" sizes="32x32" href="/bilder/favicon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/bilder/favicon/favicon-16x16.png">
@@ -23,28 +66,76 @@
<link rel="apple-touch-icon" sizes="180x180" href="/bilder/favicon/apple-touch-icon.png">
<link rel="manifest" href="/bilder/favicon/site.webmanifest">
<?php if (isset($openGraph)): extract($openGraph); ?>
<meta property="og:type" content="website" />
<meta property="og:title" content="<?= htmlspecialchars($ogTitle ?? '') ?>" />
<meta property="og:description" content="<?= htmlspecialchars($ogDescription ?? '') ?>" />
<meta property="og:image" content="<?= htmlspecialchars($ogImage ?? '') ?>" />
<meta property="og:url" content="<?= htmlspecialchars($ogUrl ?? '') ?>" />
<meta property="og:locale" content="de_DE" />
<meta property="og:site_name" content="Haus Schleusingen" />
<?php endif; ?>
<meta property="og:type" content="<?= htmlspecialchars($ogType ?? 'website', ENT_QUOTES) ?>">
<meta property="og:url" content="<?= htmlspecialchars($ogUrl ?? $canonical ?? ($t('site.canonical_base') . '/'), ENT_QUOTES) ?>">
<meta property="og:title" content="<?= htmlspecialchars($ogTitle ?? $pageTitle ?? $t('site.title'), ENT_QUOTES) ?>">
<meta property="og:description" content="<?= htmlspecialchars($ogDescription ?? $pageDescription ?? $t('site.description'), ENT_QUOTES) ?>">
<meta property="og:locale" content="<?= htmlspecialchars($ogLocale ?? Locale::toOgLocale($locale), ENT_QUOTES) ?>">
<meta property="og:site_name" content="<?= htmlspecialchars($ogSiteName ?? $t('site.name'), ENT_QUOTES) ?>">
<meta property="og:image" content="<?= htmlspecialchars($ogImage ?? ($t('site.canonical_base') . '/bilder/Außenansicht-2.png'), ENT_QUOTES) ?>">
<?php if (isset($structuredData)): ?>
<script type="application/ld+json"><?= $structuredData ?></script>
<?php endif; ?>
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/css/haus-schleusingen.css" />
<link rel="stylesheet" href="/css/haus-schleusingen.css?v=<?= @filemtime(dirname(__DIR__, 3) . '/public/css/haus-schleusingen.css') ?: time() ?>" />
<?php if (isset($extraCss)): ?>
<style><?= $extraCss ?></style>
<?php endif; ?>
</head>
<body>
<a class="skip-link" href="#main"><?= htmlspecialchars($t('nav.skip'), ENT_QUOTES) ?></a>
<nav id="navbar" aria-label="<?= htmlspecialchars($t('nav.main'), ENT_QUOTES) ?>">
<div class="nav-logo">
<a href="<?= htmlspecialchars($homeUrl) ?>" aria-label="<?= htmlspecialchars($siteName, ENT_QUOTES) ?>">
<span class="logo-icon" aria-hidden="true">🏠</span>
</a>
</div>
<ul class="nav-links" role="list">
<?php foreach ($navItems as $item): ?>
<li>
<a href="<?= htmlspecialchars($item['href']) ?>"><?= htmlspecialchars($t($item['label']), ENT_QUOTES) ?></a>
</li>
<?php endforeach; ?>
</ul>
<?= $locale_switcher($currentPath) ?>
<a class="nav-cta" href="/#kontakt"><?= htmlspecialchars($t('nav.cta'), ENT_QUOTES) ?></a>
<button class="nav-hamburger" type="button" aria-expanded="false" aria-controls="navMobile" aria-label="<?= htmlspecialchars($t('nav.toggle'), ENT_QUOTES) ?>">
<span></span>
</button>
<div id="navMobile" class="nav-mobile-overlay" hidden></div>
</nav>
<main id="main" tabindex="-1" aria-label="<?= htmlspecialchars($t('a11y.main'), ENT_QUOTES) ?>">
<?= $content ?>
</main>
<footer aria-label="<?= htmlspecialchars($t('footer.aria'), ENT_QUOTES) ?>">
<div class="footer-logo">
<span class="logo-icon" aria-hidden="true">🏠</span>
<span><?= htmlspecialchars($t('footer.address'), ENT_QUOTES) ?></span>
</div>
<div class="footer-links">
<a href="/impressum"<?= $isImpr ? ' aria-current="page"' : '' ?>><?= htmlspecialchars($t('footer.imprint'), ENT_QUOTES) ?></a>
<a href="/datenschutz"<?= $isPriv ? ' aria-current="page"' : '' ?>><?= htmlspecialchars($t('footer.privacy'), ENT_QUOTES) ?></a>
</div>
<div class="footer-bottom">© <?= date('Y') ?> <?= htmlspecialchars($siteName) ?></div>
</footer>
<div id="lightbox" class="lightbox" role="dialog" aria-modal="true" aria-label="<?= htmlspecialchars($t('lightbox.aria'), ENT_QUOTES) ?>">
<button id="lightboxClose" class="lightbox-close" type="button" aria-label="<?= htmlspecialchars($t('lightbox.close'), ENT_QUOTES) ?>">&times;</button>
<div class="lightbox-content">
<img id="lightboxImg" class="lightbox-img" alt="" />
<div id="lightboxCaption" class="lightbox-caption"></div>
</div>
</div>
<script src="/js/haus-schleusingen.js"></script>
</body>

View File

@@ -0,0 +1,123 @@
# ADR-002: Multi-Language Architecture (i18n)
**Status:** Accepted
**Date:** 2026-06-04
**Context:** Issue #71 (Epic: Multi-Language: UK/RU/EN — DE bleibt für Rechtliches)
**Deciders:** Martin (Product Owner), Hermes (Implementation)
## Context and Problem Statement
The landing page `landingpage-haus-schleusingen.de` is currently German-only.
It must support 4 languages: **DE** (default, for legal content), **EN** (UK
English), **UK** (Ukrainian), **RU** (Russian). The site is SEO-critical (real
estate listing), has no build step, and runs on stock PHP 8.x on shared hosting.
The challenge: ship server-side rendering for SEO + no FOUC, without dragging
in a heavy framework or build pipeline.
## Considered Options
1. **PHP-Server-Side-Rendering with `app/Locales/*.php` arrays + `t()` helper**
(chosen)
2. JSON translation files + JS-driven i18n (rejected — FOUC, bad SEO)
3. Full Symfony/translation component (rejected — overkill for 1-page site)
4. Static-site per language (`/de/`, `/en/`, `/uk/`, `/ru/` directories)
(rejected — duplicates routes/forms, harder to maintain)
## Decision
**Option 1: PHP SSR with `app/Locales/*.php` and a `t()` helper.**
### Components
- **`App\Core\Locale`** — locale resolution (priority: query-param `?lang=`
→ cookie → `Accept-Language` header → fallback `de`). Immutable, no
globals. Available locales: `['de', 'en', 'uk', 'ru']`.
- **`App\Core\I18n`** — translation loader + `t(string $key, array $params = [])`
function. Loads `app/Locales/{locale}.php` lazily, caches in static array.
Supports `{placeholder}` interpolation. Falls back to `de` if a key is
missing in the current locale, then to the key itself.
- **`App\Controllers\LocaleController`** — `GET /locale/{locale}` sets a
one-year `locale` cookie and 302-redirects to `Referer` (or `/`).
- **`app/Locales/{de,en,uk,ru}.php`** — flat `key => 'text'` arrays. Keys use
dotted notation (`nav.gallery`, `hero.cta`, `form.error.email`).
- **Layout** — `app/views/layouts/main.php` reads current locale from
`Locale::current()` and renders dynamic `<html lang="…">` + `og:locale`.
- **Switcher UI** — in `app/views/partials/locale_switcher.php`, embedded in
navbar. Inline SVG flag icons (no external assets).
### Locale Resolution Order
1. Query parameter `?lang=xx` (one-shot, sets cookie)
2. Cookie `locale` (1 year, `SameSite=Lax`, no `Secure` flag for HTTP test
hosts, `Secure` flag in prod via env check)
3. `Accept-Language` header — first matching language from
`['en-US', 'en', 'uk', 'ru']` (BCP-47 → ISO 639-1 mapping)
4. Fallback: `de`
### Translation File Format
```php
// app/Locales/de.php
return [
'nav.gallery' => 'Galerie',
'hero.cta' => 'Jetzt anfragen',
'form.label.email' => 'E-Mail',
];
```
`{placeholder}` interpolation, e.g.:
```php
'greeting' => 'Hallo, {name}!',
echo t('greeting', ['name' => 'Martin']); // "Hallo, Martin!"
```
### Out of Scope (this MVP)
- Right-to-left languages (Arabic, Hebrew)
- Plural forms (`{n,plural,one{...}other{...}}` ICU syntax) — flat strings only
- Admin UI for editing translations (POEditor, Crowdin, etc.)
- Per-page translation overrides
- URL-based locale (`/en/`, `/uk/`) — cookie + query only for MVP
### Trade-offs Accepted
- **No URL-based locale** → weaker SEO signal for non-default languages.
Mitigation: `og:locale` + `<html lang>` + hreflang tags (TODO post-MVP).
- **No ICU plural** → manual `{n} Zimmer` strings. Acceptable: page has
fixed numbers (`227 m²`, `6 Zimmer`).
- **Flat key namespace** (`nav.gallery` vs nested arrays) → slightly more
verbose but trivially diff-able in PRs and avoids PHP array-merging
surprises.
## Consequences
### Positive
- **Zero new dependencies** (no Composer additions, no JS framework)
- **SEO-perfect** — fully server-rendered, no FOUC
- **Trivially testable** — pure PHP, no globals, no I/O at request time
(files loaded once, cached)
- **Diff-friendly** — translation files are flat PHP arrays
- **Fast** — locale detection is in-memory; translation load happens once
per request, per locale
### Negative
- Adding a new key requires touching all 4 files (mitigated by `missing
key → fallback to DE → fallback to key string`)
- No URL canonicalization for non-DE locales (mitigated post-MVP with
hreflang)
- Manual translation review (no professional translator for UK/RU in MVP)
## Implementation Plan
Issue #71 (Epic) → #72#77 (6 sub-issues, dependency-ordered).
## References
- Issue #71: https://git.home.kies-media.de/greggy/landingpage-haus-schleusingen/issues/71
- Issue #72#77: sub-issues, all in Milestone "Multi-Language MVP"
- W3C i18n tutorials: https://www.w3.org/International/tutorials/
- BCP-47 language tags: https://datatracker.ietf.org/doc/html/rfc5646

View File

@@ -0,0 +1,142 @@
# Ph4 — Deployment- und Test-Report (Multi-Language Feature)
> Phase 4/4 des Dev-Orchestrator-Workflows für Issue **#71** (Epic).
> Verantwortlich: Hermes (Implementierung) / Martin (Approval & Merge).
> Stand: nach PR **#78** (offen, nicht gemerged).
## 1. Übersicht
| Sub-Issue | Bereich | Commit | Status |
| --------- | -------------------------------- | --------- | -------- |
| #72 | Core (Locale, I18n, Tests) | `63c8c75` | ✅ |
| #73 | LocaleController + Open-Redirect | `ce21242` | ✅ |
| #74 | Locales + Layout | `4b1c779` | ✅ |
| #75 | Locale-Switcher UI | `0186de9` | ✅ |
| #76 | Accessibility (A11y) | `13a25ad` | ✅ |
| #77 | Integration + E2E + Coverage | `a1984b9` | ✅ |
| — | Cleanup: dead `app/controllers/` | `c5a608d` | ✅ |
| **PR** | **#78 — i18n Epic** | — | 🟡 offen |
## 2. Architektur-Entscheidungen (ADR-002)
- **Server-Side-Rendering (PHP)** — kein SPA, kein Static-Site. Begründung: SEO (`<html lang>`, `og:locale`, übersetzte `<title>`), kein FOUC, günstiger als JSON-Payload.
- **4 Sprachen** — DE (default, Quelle der Wahrheit) / EN-GB / UK / RU.
- **Storage** — `app/Locales/{de,en,uk,ru}.php` als PHP-Arrays (kein JSON, keine DB).
- **Resolution-Priorität** — `?lang=` > Cookie > `Accept-Language` > `de`-Fallback.
- **A11y** — separate ARIA-Labels für `<main>` (`a11y.main`) und `<nav>` (`a11y.nav`), 44px Touch-Targets für Flaggen, `aria-current="true"` auf aktiver Sprache, per-field form errors mit `aria-invalid` + `aria-describedby`.
- **Rechtliches** — Impressum/Datenschutz bleiben deutsch (§ 5 TMG/DSGVO), nur Navigation/Headings werden übersetzt.
## 3. Pre-Merge-Checkliste
| Item | Status |
| ----------------------------------------------------- | ------ |
| Branch `feature/multilanguage-mvp` erstellt & gepusht | ✅ |
| 7 Commits mit `closes #<sub-issue>` Messages | ✅ |
| 140 PHPUnit-Tests, 2493 Assertions | ✅ |
| I18n-Coverage 97% / Locale 100% (Ziel ≥85%) | ✅ |
| E2E Flow (Playwright) für 4 Locales grün | ✅ |
| Pre-Commit-Hooks + Safe-Commit-Script | ✅ |
| Keine `print`/`echo` in Production-Code | ✅ |
| Kein `sleep()` / keine Test-Order-Dependencies | ✅ |
| ADR-002 in `docs/adr/` | ✅ |
| Dead `app/controllers/` (lowercase) entfernt | ✅ |
## 4. Smoke-Test nach Merge (Test-Umgebung)
Domain: `https://haus.test.kies-media.de`
### 4.1 Sprachauflösung
| URL | Erwartet |
| -------------------------------- | ------------- |
| `/` (ohne Cookie) | Default DE |
| `/?lang=en` | EN-GB Content |
| `/?lang=uk` | UK Content |
| `/?lang=ru` | RU Content |
| `/?lang=fr` (ungültig) | Fallback DE |
| Mit gesetztem `locale=en` Cookie | EN-GB Content |
### 4.2 Sichtprüfung pro Sprache
- [ ] `<html lang="<code>">` korrekt
- [ ] `<title>` übersetzt
- [ ] Hero-Headlines übersetzt
- [ ] Navigation-Labels übersetzt
- [ ] Footer-aria-Label übersetzt
- [ ] `og:locale` korrekt (de_DE / en_GB / uk_UA / ru_RU)
- [ ] Locale-Switcher zeigt aktive Sprache mit `aria-current="true"`
- [ ] Mind. ein `hreflang="<code>"`-Link pro Sprache im `<head>`
### 4.3 Funktionale Tests
- [ ] Klick auf Flagge → URL `?lang=<code>` → Cookie gesetzt → Content gewechselt
- [ ] Open-Redirect-Schutz: `?lang=en&redirect=https://evil.example` → Redirect bleibt auf eigener Domain
- [ ] Form-Submit funktioniert in allen 4 Sprachen (deutsche Fehlermeldungen auf `/en`-Seite bleiben — gewollt, da Validation-Server-Side)
- [ ] Mobile: Flaggen ≥44px Touch-Target, Hamburger-Nav funktioniert
- [ ] Keyboard: Tab durch Switcher, Enter aktiviert, ESC schließt mobile Nav
- [ ] Screen-Reader-Test (VoiceOver / NVDA): Locale-Switcher ankündigt aktive Sprache, Form-Fehler werden vorgelesen
### 4.4 Legal-Pages (DE-only)
- [ ] `/impressum` und `/datenschutz` zeigen deutschen Textkörper
- [ ] Navigation auf diesen Seiten ist übersetzt, Body nicht
- [ ] `<html lang>` ist `de` auf diesen Seiten
## 5. Performance- und SEO-Checkliste
- [ ] `view-source:` zeigt übersetzte Texte (kein `{{t()}}`-Placeholder)
- [ ] Lighthouse-Score: Performance ≥90, SEO ≥95, A11y ≥95
- [ ] Keine Layout-Shifts beim Locale-Wechsel
- [ ] `hreflang` Alternate-Links vollständig (`de`, `en-GB`, `uk`, `ru`)
- [ ] `canonical`-Link zeigt auf kanonische URL (ohne `?lang=`)
## 6. Rollback-Strategie
**Falls nach Deploy Probleme auftauchen:**
1. **Schnellster Rollback** — PR revert:
```bash
git revert -m 1 <merge-commit>
git push origin main
```
2. **Selektiver Rollback** — einzelne Sub-Issue-Commits rückwärts:
```bash
git revert c5a608d # cleanup
git revert a1984b9 # F
git revert 13a25ad # E
git revert 0186de9 # D
git revert 4b1c779 # C
```
3. **Branch-only Rollback** — `main` zurücksetzen, Branch behalten für Hotfix:
```bash
git checkout main
git reset --hard <commit-vor-merge>
git push --force-with-lease
```
4. **Cookie-Cleanup** — falls User mit gesetztem `locale=en` auf alte Version zurückgehen, ist das harmlos (Cookie wird ignoriert).
**Daten-Migration:** keine — Feature ist additiv (keine DB-Änderungen, keine Schema-Breaks).
## 7. Risiken & Annahmen
- **Annahme:** Reines SSR reicht aus, kein Lazy-Loading pro Sprache nötig.
- **Risiko:** Bestehende User ohne `locale`-Cookie sehen DE (gewollt).
- **Risiko:** `Accept-Language: ru` von Bots könnte Page-Weight verfälschen — irrelevant für SEO, da `hreflang` Vorrang hat.
- **Annahme:** Übersetzungen in `app/Locales/*.php` sind von Muttersprachlern reviewt. **Aktion:** Martin lässt DE-Original von UK/RU-Sprecher gegenlesen.
## 8. Post-Merge Follow-Ups (Backlog)
- [ ] Übersetzungs-Review durch Muttersprachler
- [ ] Analytics: Sprache als Custom-Dimension tracken
- [ ] Lazy-Loading von Übersetzungen falls Bundle wächst (>50 KB)
- [ ] `de.php` als TypeScript-Schema für Frontend-Vue (zukünftig)
- [ ] CI-Workflow für Playwright E2E (statt manuell)
## 9. Sign-off
| Rolle | Name | Datum | Freigabe |
| --------------- | ------ | ---------- | ------------- |
| Implementierung | Hermes | 2026-06-04 | ✅ |
| Review & Merge | Martin | — | 🟡 ausstehend |
**Merge-Freigabe:** Martin mit 'merge PR #78' (siehe gitea-dev-orchestrator Memory).

View File

@@ -4,10 +4,10 @@
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache"
failOnWarning="true"
failOnRisky="true"
failOnEmptyTestSuite="true"
beStrictAboutOutputDuringTests="true"
failOnPhpunitWarning="false"
>
<testsuites>
<testsuite name="Unit">
@@ -20,4 +20,12 @@
<directory>app</directory>
</include>
</source>
<coverage cacheDirectory=".phpunit.coverage.cache">
<report>
<clover outputFile="build/coverage/clover.xml"/>
<html outputDirectory="build/coverage/html" lowUpperBound="50" highLowerBound="85"/>
<text outputFile="php://stdout" showOnlySummary="true"/>
</report>
</coverage>
</phpunit>

View File

@@ -50,11 +50,22 @@ a:focus-visible {
--cream: #f5f0e8;
--warm: #e8dfd0;
--stone: #7a7062;
--stone-strong: #5a5043;
--dark: #1c1a17;
--charcoal: #2e2b26;
--accent: #8b6914;
--accent-light: #c49a2a;
--accent-strong: #5a450d;
--white: #fdfcfa;
/* Text variants — keep light text consistent across dark backgrounds */
--text-muted: #6e6557;
--text-muted-on-dark: rgb(245 240 232 / 82%);
--text-faint-on-dark: rgb(245 240 232 / 65%);
/* Nav: always visible, glass effect on top of hero */
--nav-bg: rgb(253 252 250 / 92%);
--nav-border: rgb(232 223 208 / 70%);
}
*,
@@ -76,7 +87,7 @@ body {
overflow-x: hidden;
}
/* NAV */
/* NAV — always visible, glass effect over hero */
nav {
position: fixed;
top: 0;
@@ -86,38 +97,53 @@ nav {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 3rem;
background: transparent;
padding: 0.95rem 3rem;
background: var(--nav-bg);
backdrop-filter: saturate(180%) blur(14px);
border-bottom: 1px solid var(--nav-border);
transition:
background 0.4s,
padding 0.4s;
padding 0.3s ease,
box-shadow 0.3s ease;
box-shadow: 0 1px 12px rgb(28 26 23 / 4%);
}
nav.scrolled {
background: var(--white);
backdrop-filter: none;
padding: 0.85rem 3rem;
border-bottom: 1px solid var(--warm);
box-shadow: 0 1px 8px rgb(0 0 0 / 6%);
padding: 0.65rem 3rem;
box-shadow: 0 2px 16px rgb(28 26 23 / 8%);
}
.nav-logo {
.nav-logo a {
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
color: var(--dark);
font-family: "Cormorant Garamond", serif;
font-size: 1.15rem;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--white);
transition: color 0.4s;
}
nav.scrolled .nav-logo {
color: var(--dark);
.nav-logo a:hover {
color: var(--accent);
}
.logo-icon {
font-size: 1.4rem;
line-height: 1;
}
/* Hide logo text everywhere — logo icon is the only brand mark */
.logo-text {
display: none !important;
}
.nav-links {
display: flex;
gap: 2.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.nav-links a {
@@ -126,16 +152,11 @@ nav.scrolled .nav-logo {
letter-spacing: 0.1em;
text-transform: uppercase;
text-decoration: none;
color: rgb(255 255 255 / 85%);
color: var(--stone-strong);
transition: color 0.3s;
}
nav.scrolled .nav-links a {
color: var(--stone);
}
.nav-links a:hover,
nav.scrolled .nav-links a:hover {
.nav-links a:hover {
color: var(--accent);
}
@@ -156,12 +177,14 @@ nav.scrolled .nav-links a:hover {
background 0.3s,
transform 0.2s,
box-shadow 0.3s;
text-decoration: none;
}
.nav-cta:hover {
background: var(--accent-light);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgb(139 105 20 / 50%);
color: var(--white);
}
/* HAMBURGER */
@@ -177,6 +200,7 @@ nav.scrolled .nav-links a:hover {
align-items: center;
justify-content: center;
padding: 0;
color: var(--dark);
}
.nav-hamburger span,
@@ -185,13 +209,14 @@ nav.scrolled .nav-links a:hover {
display: block;
width: 22px;
height: 2px;
background: var(--white);
background: var(--dark);
border-radius: 1px;
transition:
transform 0.3s ease,
opacity 0.3s ease,
background 0.4s;
opacity 0.3s ease;
position: absolute;
left: 50%;
top: 50%;
}
.nav-hamburger span::before,
@@ -200,30 +225,25 @@ nav.scrolled .nav-links a:hover {
}
.nav-hamburger span::before {
transform: translateY(-7px);
transform: translate(-50%, calc(-50% - 7px));
}
.nav-hamburger span::after {
transform: translateY(7px);
}
nav.scrolled .nav-hamburger span,
nav.scrolled .nav-hamburger span::before,
nav.scrolled .nav-hamburger span::after {
background: var(--dark);
transform: translate(-50%, calc(-50% + 7px));
}
.nav-hamburger.active span {
background: transparent;
transform: translate(-50%, -50%);
}
.nav-hamburger.active span::before {
transform: rotate(45deg);
transform: translate(-50%, -50%) rotate(45deg);
background: var(--dark);
}
.nav-hamburger.active span::after {
transform: rotate(-45deg);
transform: translate(-50%, -50%) rotate(-45deg);
background: var(--dark);
}
@@ -255,10 +275,13 @@ nav.scrolled .nav-hamburger span::after {
.hero-bg {
position: absolute;
inset: 0;
background-size: cover;
background-position: center 20%;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center 20%;
transform: scale(1.05);
transition: transform 8s ease-out;
z-index: 0;
}
.hero-bg.loaded {
@@ -268,12 +291,15 @@ nav.scrolled .nav-hamburger span::after {
.hero-overlay {
position: absolute;
inset: 0;
z-index: 1;
background: linear-gradient(
to top,
rgb(28 26 23 / 85%) 0%,
rgb(28 26 23 / 30%) 50%,
rgb(28 26 23 / 10%) 100%
rgb(20 18 15 / 92%) 0%,
rgb(20 18 15 / 60%) 30%,
rgb(20 18 15 / 35%) 60%,
rgb(20 18 15 / 20%) 100%
);
pointer-events: none;
}
.hero-content {
@@ -303,6 +329,7 @@ nav.scrolled .nav-hamburger span::after {
text-transform: uppercase;
color: var(--accent-light);
margin-bottom: 1.25rem;
text-shadow: 0 1px 8px rgb(0 0 0 / 40%);
}
.hero-tag::before {
@@ -320,6 +347,9 @@ nav.scrolled .nav-hamburger span::after {
color: var(--white);
letter-spacing: -0.01em;
margin-bottom: 1.5rem;
text-shadow:
0 2px 24px rgb(0 0 0 / 50%),
0 1px 3px rgb(0 0 0 / 30%);
}
.hero h1 em {
@@ -332,8 +362,9 @@ nav.scrolled .nav-hamburger span::after {
gap: 2.5rem;
align-items: center;
font-size: 0.82rem;
color: rgb(255 255 255 / 60%);
color: var(--text-muted-on-dark);
letter-spacing: 0.05em;
text-shadow: 0 1px 6px rgb(0 0 0 / 40%);
}
.hero-meta strong {
@@ -415,7 +446,7 @@ nav.scrolled .nav-hamburger span::after {
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--stone);
color: var(--text-muted-on-dark);
}
/* INTRO */
@@ -435,10 +466,10 @@ nav.scrolled .nav-hamburger span::after {
.section-eyebrow {
font-size: 0.72rem;
font-weight: 500;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--accent);
color: var(--accent-strong);
margin-bottom: 1rem;
}
@@ -570,7 +601,7 @@ nav.scrolled .nav-hamburger span::after {
font-size: 0.72rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgb(255 255 255 / 90%);
color: var(--text-muted-on-dark);
border-radius: 0 0 3px 3px;
transform: translateY(6px);
opacity: 0;
@@ -806,15 +837,21 @@ nav.scrolled .nav-hamburger span::after {
font-size: 0.72rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--stone);
color: var(--text-muted-on-dark);
margin-bottom: 1rem;
}
.price-card.highlight .pc-label {
color: rgb(255 255 255 / 70%);
.price-card .price-label {
display: block;
font-size: 0.72rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text-muted-on-dark);
margin-bottom: 0.8rem;
}
.pc-val {
.price-card .price-value {
display: block;
font-family: "Cormorant Garamond", serif;
font-size: 2.8rem;
font-weight: 600;
@@ -823,13 +860,35 @@ nav.scrolled .nav-hamburger span::after {
margin-bottom: 0.3rem;
}
.pc-sub {
.price-card .price-unit {
display: block;
font-size: 0.78rem;
color: var(--stone);
color: var(--text-muted-on-dark);
}
.price-card.highlight .pc-sub {
color: rgb(255 255 255 / 70%);
.price-card.highlight .price-label,
.price-card.highlight .price-unit {
color: var(--text-muted-on-dark);
}
.pricing-section .rent-notes {
margin-top: 2.5rem;
display: grid;
grid-template-columns: 12rem 1fr;
gap: 0.5rem 1.5rem;
}
.pricing-section .rent-notes dt {
font-size: 0.78rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--text-muted-on-dark);
}
.pricing-section .rent-notes dd {
margin: 0;
color: var(--white);
font-size: 0.95rem;
}
.price-note {
@@ -855,7 +914,7 @@ nav.scrolled .nav-hamburger span::after {
/* LAGE */
.lage-section {
padding: 6rem 3rem;
padding: 6rem 3rem 0;
max-width: 1200px;
margin: 0 auto;
}
@@ -870,58 +929,79 @@ nav.scrolled .nav-hamburger span::after {
.lage-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1px;
background: var(--warm);
grid-template-columns: repeat(3, 1fr);
gap: 1.5rem;
margin-bottom: 4rem;
}
.lage-item {
.lage-features {
list-style: none;
margin: 0;
padding: 0;
display: contents;
}
.lage-features > li {
background: var(--white);
padding: 2rem;
display: flex;
gap: 1.25rem;
align-items: flex-start;
transition: background 0.3s;
flex-direction: column;
gap: 0.5rem;
border: 1px solid var(--warm);
border-radius: 2px;
transition:
background 0.3s,
transform 0.3s,
box-shadow 0.3s;
}
.lage-item:hover {
.lage-features > li::before {
content: "";
display: block;
width: 32px;
height: 32px;
background: var(--accent);
mask: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5z'/></svg>")
center/contain no-repeat;
margin-bottom: 0.5rem;
}
.lage-features > li:hover {
background: var(--cream);
transform: translateY(-2px);
box-shadow: 0 6px 24px rgb(28 26 23 / 8%);
}
.lage-icon {
width: 40px;
height: 40px;
flex-shrink: 0;
background: var(--cream);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
color: var(--accent);
.lage-feature-title {
font-size: 1rem;
font-weight: 600;
color: var(--dark);
letter-spacing: 0.01em;
}
.lage-title {
font-size: 0.9rem;
font-weight: 500;
margin-bottom: 4px;
}
.lage-desc {
font-size: 0.82rem;
color: var(--stone);
.lage-feature-desc {
font-size: 0.88rem;
color: var(--text-muted);
line-height: 1.6;
}
.lage-map-wrapper {
margin-top: 3rem;
border: 1px solid var(--warm);
position: relative;
left: 50%;
right: 50%;
width: 100vw;
margin: 0 -50vw;
border-top: 1px solid var(--warm);
border-bottom: 1px solid var(--warm);
background: var(--white);
overflow: hidden;
}
.lage-map-wrapper iframe {
display: block;
width: 100%;
height: 450px;
height: 480px;
border: 0;
filter: grayscale(30%) contrast(1.05);
transition: filter 0.4s ease;
}
@@ -930,10 +1010,34 @@ nav.scrolled .nav-hamburger span::after {
filter: grayscale(0%) contrast(1);
}
.lage-address {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem 3rem;
font-size: 0.9rem;
line-height: 1.6;
color: var(--text-muted);
text-align: center;
}
.lage-address strong {
color: var(--dark);
font-weight: 600;
}
@media (width <= 900px) {
.lage-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.lage-map-wrapper iframe {
height: 320px;
}
.lage-address {
padding: 1.25rem 1.5rem;
}
}
/* CONTACT */
@@ -1000,44 +1104,74 @@ nav.scrolled .nav-hamburger span::after {
font-size: 0.88rem;
background: var(--white);
border: 1px solid var(--warm);
padding: 0.75rem 1rem;
border-radius: 2px;
padding: 0.85rem 1rem;
color: var(--dark);
outline: none;
transition: border-color 0.2s;
transition:
border-color 0.2s,
box-shadow 0.2s;
resize: none;
}
.form-field input:hover,
.form-field textarea:hover,
.form-field select:hover {
border-color: var(--stone);
}
.form-field input:focus,
.form-field textarea:focus,
.form-field select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgb(139 105 20 / 12%);
}
.form-field select {
appearance: none;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'><path d='M1 1l5 5 5-5' stroke='%237a7062' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
background-repeat: no-repeat;
background-position: right 1rem center;
padding-right: 2.5rem;
cursor: pointer;
}
.form-field.full {
grid-column: span 2;
}
.btn-submit {
.btn-submit,
.form-submit {
font-family: "DM Sans", sans-serif;
font-size: 0.78rem;
font-weight: 500;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
background: var(--accent);
color: var(--white);
border: none;
border-radius: 2px;
width: 100%;
padding: 1.1rem;
padding: 1.15rem;
cursor: pointer;
margin-top: 0.5rem;
box-shadow: 0 2px 12px rgb(139 105 20 / 25%);
transition:
background 0.3s,
transform 0.2s;
transform 0.2s,
box-shadow 0.3s;
}
.btn-submit:hover {
.btn-submit:hover,
.form-submit:hover {
background: var(--accent-light);
transform: translateY(-1px);
box-shadow: 0 4px 20px rgb(139 105 20 / 40%);
}
.btn-submit:active,
.form-submit:active {
transform: translateY(0);
}
.form-errors {
@@ -1089,6 +1223,25 @@ nav.scrolled .nav-hamburger span::after {
color: var(--stone);
}
.contact-direct {
text-align: center;
margin-top: 1.5rem;
font-size: 0.85rem;
color: var(--text-muted);
}
.contact-direct a {
color: var(--accent);
text-decoration: none;
font-weight: 600;
transition: color 0.2s;
}
.contact-direct a:hover {
color: var(--accent-light);
text-decoration: underline;
}
.contact-details {
text-align: center;
margin-top: 2rem;
@@ -1187,12 +1340,13 @@ footer {
/* RESPONSIVE */
@media (width <= 900px) {
nav {
nav,
nav.scrolled {
padding: 1rem 1.5rem;
}
nav.scrolled {
padding: 0.75rem 1.5rem;
.nav-cta {
display: none;
}
.nav-links {
@@ -1203,6 +1357,11 @@ footer {
display: flex;
}
/* Logo: keep icon, hide text on small viewports */
.logo-text {
display: none;
}
/* Mobile slide-down nav */
nav.mobile-open .nav-links {
display: flex;
@@ -1254,6 +1413,9 @@ footer {
.hero-content {
padding: 0 1.5rem 4rem;
max-width: 100%;
min-width: 0;
width: 100%;
}
.facts-strip {
@@ -1270,11 +1432,25 @@ footer {
padding: 3rem 1.5rem;
}
.intro-grid {
min-width: 0;
}
.intro-text {
padding-right: 0;
margin-bottom: 2rem;
}
.intro-stats {
flex-wrap: wrap;
gap: 1.5rem 2rem;
}
.intro-stats > .stat {
flex: 1 1 auto;
min-width: 0;
}
.masonry-grid {
column-count: 2;
}
@@ -1310,9 +1486,181 @@ footer {
padding: 4rem 1.5rem;
}
.pricing-section {
padding: 4rem 1.5rem;
}
.pricing-section .rent-notes {
grid-template-columns: 1fr;
gap: 0.25rem 0;
}
footer {
flex-direction: column;
gap: 1.5rem;
text-align: center;
}
}
/* LOCALE SWITCHER — single <details> dropdown, flag-sized trigger.
Design goals (after Martin-feedback round 3):
- The flag is the visual anchor of every row, not a tiny icon
drowning in padding.
- Trigger is a 44×44 touch target with the flag centred, no
artificial 1px outline (real flag-icons need no border).
- Menu rows are 40px+ tall with comfortable flag+label spacing.
- No FOUC on open: SVGs load eager (4 small files). */
.locale-switcher {
position: relative;
display: inline-block;
}
.locale-switcher__trigger {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px;
min-height: 44px;
border: none;
border-radius: 6px;
background: transparent;
cursor: pointer;
list-style: none;
color: var(--dark);
transition:
background 0.2s,
transform 0.15s;
}
.locale-switcher__trigger::-webkit-details-marker {
display: none;
}
.locale-switcher__trigger::marker {
content: "";
}
.locale-switcher__trigger:hover,
.locale-switcher__trigger:focus-visible {
background: rgb(0 0 0 / 6%);
outline: none;
}
.locale-switcher__current {
display: inline-flex;
align-items: center;
}
.locale-switcher__caret {
font-size: 0.7rem;
line-height: 1;
color: inherit;
transition: transform 0.2s ease;
}
.locale-switcher[open] .locale-switcher__caret {
transform: rotate(180deg);
}
.locale-switcher__menu,
.locale-switcher__menu li {
list-style: none;
}
.locale-switcher__menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 180px;
margin: 0;
padding: 6px;
background: var(--white);
border: 1px solid var(--warm);
border-radius: 10px;
box-shadow:
0 1px 2px rgb(0 0 0 / 6%),
0 8px 24px rgb(0 0 0 / 14%);
z-index: 60;
}
.locale-switcher__option {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: 6px;
text-decoration: none;
color: var(--dark);
font-size: 0.9rem;
font-weight: 500;
}
.locale-switcher__option.is-current {
background: var(--cream);
color: var(--accent-strong);
font-weight: 600;
}
.locale-switcher__option:hover,
.locale-switcher__option:focus-visible {
background: var(--warm);
outline: none;
}
/* Flag is the visual anchor: 32×24, no border, no rounded corners
(flags look better as crisp rectangles than as pills). */
.locale-switcher .flag {
width: 32px;
height: 24px;
flex: 0 0 32px;
display: block;
}
.locale-switcher__label {
white-space: nowrap;
}
/* Trigger on transparent nav (top-of-page): white caret on dark bg */
nav:not(.scrolled) .locale-switcher__trigger {
color: var(--white);
}
nav:not(.scrolled) .locale-switcher__trigger:hover,
nav:not(.scrolled) .locale-switcher__trigger:focus-visible {
background: rgb(255 255 255 / 12%);
}
/* Flag stays the same regardless of nav state — SVG defines its own colours */
/* VISUALLY HIDDEN (a11y) */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
/* FORM FIELD ERRORS (sub-Issue E) */
.form-field-error {
margin: 0.375rem 0 0;
font-size: 0.875rem;
color: #b91c1c;
line-height: 1.4;
}
.form-field input[aria-invalid="true"],
.form-field textarea[aria-invalid="true"] {
border-color: #b91c1c;
outline-color: #b91c1c;
}
.form-field input[aria-invalid="true"]:focus-visible,
.form-field textarea[aria-invalid="true"]:focus-visible {
outline: 2px solid #b91c1c;
outline-offset: 2px;
}

5
public/img/flags/de.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-de" viewBox="0 0 640 480">
<path fill="#fc0" d="M0 320h640v160H0z"/>
<path fill="#000001" d="M0 0h640v160H0z"/>
<path fill="red" d="M0 160h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 221 B

7
public/img/flags/gb.svg Normal file
View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-gb" viewBox="0 0 640 480">
<path fill="#012169" d="M0 0h640v480H0z"/>
<path fill="#FFF" d="m75 0 244 181L562 0h78v62L400 241l240 178v61h-80L320 301 81 480H0v-60l239-178L0 64V0z"/>
<path fill="#C8102E" d="m424 281 216 159v40L369 281zm-184 20 6 35L54 480H0zM640 0v3L391 191l2-44L590 0zM0 0l239 176h-60L0 42z"/>
<path fill="#FFF" d="M241 0v480h160V0zM0 160v160h640V160z"/>
<path fill="#C8102E" d="M0 193v96h640v-96zM273 0v480h96V0z"/>
</svg>

After

Width:  |  Height:  |  Size: 504 B

5
public/img/flags/ru.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ru" viewBox="0 0 640 480">
<path fill="#fff" d="M0 0h640v160H0z"/>
<path fill="#0039a6" d="M0 160h640v160H0z"/>
<path fill="#d52b1e" d="M0 320h640v160H0z"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

6
public/img/flags/ua.svg Normal file
View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" id="flag-icons-ua" viewBox="0 0 640 480">
<g fill-rule="evenodd" stroke-width="1pt">
<path fill="gold" d="M0 0h640v480H0z"/>
<path fill="#0057b8" d="M0 0h640v240H0z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 232 B

View File

@@ -18,6 +18,7 @@ $router = new Router();
$router->addRoute('/', \App\Controllers\HomeController::class, 'index');
$router->addRoute('/impressum', \App\Controllers\ImpressumController::class, 'index');
$router->addRoute('/datenschutz', \App\Controllers\DatenschutzController::class, 'index');
$router->addRoute('/locale', \App\Controllers\LocaleController::class, 'switch');
// Dispatch
$uri = $_SERVER['REQUEST_URI'] ?? '/';

View File

@@ -0,0 +1,161 @@
<?php
declare(strict_types=1);
namespace Tests\Controllers;
use App\Controllers\LocaleController;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class LocaleControllerTest extends TestCase
{
// ──────────────────────────────────────────────
// buildResponse() — happy path
// ──────────────────────────────────────────────
public function testSetsCookieAndRedirectsOnValidLocale(): void
{
$resp = LocaleController::buildResponse('en', '/foo', 'https://example.com/bar', false);
$this->assertSame(302, $resp['status']);
$this->assertSame('/foo', $resp['redirect']);
$this->assertTrue($resp['set_cookie']);
$this->assertSame('en', $resp['cookie_value']);
$this->assertGreaterThan(time(), $resp['cookie_expires']);
}
public function testCookieExpiresInOneYear(): void
{
$before = time();
$resp = LocaleController::buildResponse('uk', '/', null, false);
$after = time();
$expected = 60 * 60 * 24 * 365;
$this->assertGreaterThanOrEqual($before + $expected, $resp['cookie_expires']);
$this->assertLessThanOrEqual($after + $expected, $resp['cookie_expires']);
}
public function testCookieSecureFlagMatchesHttps(): void
{
$http = LocaleController::buildResponse('en', '/', null, false);
$https = LocaleController::buildResponse('en', '/', null, true);
$this->assertFalse($http['cookie_secure']);
$this->assertTrue($https['cookie_secure']);
}
public function testSupportsAllFourLocales(): void
{
foreach (['de', 'en', 'uk', 'ru'] as $code) {
$resp = LocaleController::buildResponse($code, '/', null, false);
$this->assertTrue($resp['set_cookie'], "Locale {$code} should set cookie");
$this->assertSame($code, $resp['cookie_value']);
}
}
// ──────────────────────────────────────────────
// buildResponse() — invalid locale
// ──────────────────────────────────────────────
public function testInvalidLocaleDoesNotSetCookie(): void
{
$resp = LocaleController::buildResponse('fr', '/', null, false);
$this->assertFalse($resp['set_cookie']);
$this->assertSame('', $resp['cookie_value']);
}
public function testInvalidLocaleStillRedirects(): void
{
$resp = LocaleController::buildResponse('fr', '/safe-path', null, false);
$this->assertSame(302, $resp['status']);
$this->assertSame('/safe-path', $resp['redirect']);
}
public function testNullLocaleDoesNotSetCookie(): void
{
$resp = LocaleController::buildResponse(null, '/', null, false);
$this->assertFalse($resp['set_cookie']);
}
public function testEmptyStringLocaleDoesNotSetCookie(): void
{
$resp = LocaleController::buildResponse('', '/', null, false);
$this->assertFalse($resp['set_cookie']);
}
// ──────────────────────────────────────────────
// safeRedirect() — return URL sanitization
// ──────────────────────────────────────────────
#[DataProvider('provideOpenRedirectAttempts')]
public function testRejectsOpenRedirects(string $bad, string $expected): void
{
$resp = LocaleController::buildResponse('en', $bad, null, false);
$this->assertSame($expected, $resp['redirect']);
}
public static function provideOpenRedirectAttempts(): array
{
return [
'absolute https' => ['https://evil.com/phish', '/'],
'absolute http' => ['http://evil.com/phish', '/'],
'protocol-relative' => ['//evil.com/phish', '/'],
'scheme-relative upper' => ['//EVIL.COM/phish', '/'],
'javascript scheme' => ['javascript:alert(1)', '/'],
'data scheme' => ['data:text/html,<script>', '/'],
'no leading slash' => ['foo/bar', '/'],
'backslash trick' => ['/\\evil.com', '/'],
'double backslash' => ['\\\\evil.com', '/'],
];
}
#[DataProvider('provideValidRelativePaths')]
public function testAcceptsValidRelativePaths(string $path): void
{
$resp = LocaleController::buildResponse('en', $path, null, false);
$this->assertSame($path, $resp['redirect']);
}
public static function provideValidRelativePaths(): array
{
return [
'root' => ['/'],
'home' => ['/'],
'impressum' => ['/impressum'],
'datenschutz' => ['/datenschutz'],
'with query' => ['/foo?bar=1'],
'with hash' => ['/foo#section'],
'with deep' => ['/some/deep/path'],
];
}
// ──────────────────────────────────────────────
// Referer fallback chain
// ──────────────────────────────────────────────
public function testFallsBackToRefererWhenExplicitReturnIsNull(): void
{
$resp = LocaleController::buildResponse('en', null, 'https://example.com/landing', false);
// Referer is absolute, gets stripped to '/'
$this->assertSame('/', $resp['redirect']);
}
public function testFallsBackToRootWhenBothExplicitAndRefererMissing(): void
{
$resp = LocaleController::buildResponse('en', null, null, false);
$this->assertSame('/', $resp['redirect']);
}
public function testFallsBackToRootWhenRefererIsEmpty(): void
{
$resp = LocaleController::buildResponse('en', null, '', false);
$this->assertSame('/', $resp['redirect']);
}
public function testExplicitReturnBeatsReferer(): void
{
$resp = LocaleController::buildResponse('en', '/chosen', 'https://other.com/other', false);
$this->assertSame('/chosen', $resp['redirect']);
}
}

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Tests\Controllers;
use App\Controllers\LocaleSwitcher;
use App\Core\Locale;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Renders the language switcher widget and checks that:
* - exactly one <details class="locale-switcher"> dropdown,
* - 4 menu items, one per supported locale,
* - the active locale is marked aria-current="true" and is a <span>,
* - inactive locales are <a> links to /locale?set=...&return=...,
* - the trigger and every menu item contain a flag SVG,
* - the rendered label is in the current locale's language.
*/
final class LocaleSwitcherTest extends TestCase
{
#[Test]
public function rendersSingleDropdownForAllSupportedLocales(): void
{
$html = (new LocaleSwitcher('en', '/'))->render();
// exactly one <details class="locale-switcher"> (no -mobile suffix, no desktop <ul>)
self::assertStringContainsString('<details class="locale-switcher">', $html);
self::assertStringNotContainsString('locale-switcher-mobile', $html);
self::assertStringNotContainsString('<ul class="locale-switcher"', $html);
self::assertStringNotContainsString('locale-switcher__item', $html);
// the menu lists all 4 supported locales
self::assertSame(4, substr_count($html, 'class="locale-switcher__option'), 'expected 4 menu options');
// The 3 inactive locales render as <a hreflang="..">. The active
// locale renders as <span lang=".."> (no hreflang). Together all
// 4 must be present in either form.
foreach (Locale::SUPPORTED as $code) {
self::assertTrue(
str_contains($html, 'hreflang="' . $code . '"') || str_contains($html, 'lang="' . $code . '"'),
"locale '$code' is missing from switcher",
);
}
// 1 flag in trigger + 4 flags in menu = 5 total
self::assertSame(5, substr_count($html, 'class="flag"'), 'expected 5 flag SVGs (1 trigger + 4 menu)');
}
#[Test]
public function marksCurrentLocaleWithAriaCurrentAndSpan(): void
{
$html = (new LocaleSwitcher('uk', '/'))->render();
self::assertStringContainsString('is-current', $html);
self::assertStringContainsString('aria-current="true"', $html);
self::assertStringContainsString('lang="uk"', $html);
// active option must be a <span>, not an <a>
self::assertMatchesRegularExpression(
'/<span class="locale-switcher__option is-current"[^>]*aria-current="true"[^>]*lang="uk"/',
$html,
);
}
#[Test]
public function inactiveLocalesAreLinksToLocaleController(): void
{
$html = (new LocaleSwitcher('de', '/foo/bar'))->render();
self::assertStringContainsString('href="/locale?set=en&amp;return=%2Ffoo%2Fbar"', $html);
self::assertStringContainsString('href="/locale?set=uk&amp;return=%2Ffoo%2Fbar"', $html);
self::assertStringContainsString('href="/locale?set=ru&amp;return=%2Ffoo%2Fbar"', $html);
}
#[Test]
public function stripsQueryAndFragmentFromReturnPath(): void
{
$html = (new LocaleSwitcher('de', '/?lang=uk#kontakt'))->render();
// sanitisePath keeps only the path part
self::assertStringContainsString('return=%2F', $html);
self::assertStringNotContainsString('return=%2F%3Flang', $html);
self::assertStringNotContainsString('return=%2F%23kontakt', $html);
}
#[Test]
public function rejectsPathsThatDoNotStartWithSlash(): void
{
$html = (new LocaleSwitcher('de', 'https://evil.example/'))->render();
// sanitisePath falls back to '/'
self::assertStringContainsString('return=%2F', $html);
self::assertStringNotContainsString('evil.example', $html);
}
/**
* @return array<string, array{string, string}>
*/
public static function flagDataProvider(): array
{
return [
'DE Germany' => ['de', 'de.svg'],
'EN en-GB' => ['en', 'gb.svg'],
'UK Ukraine' => ['uk', 'ua.svg'],
'RU Russia' => ['ru', 'ru.svg'],
];
}
#[Test]
public function flagImgReturnsValidImgForEverySupportedLocale(): void
{
foreach (Locale::SUPPORTED as $code) {
$img = LocaleSwitcher::flagImg($code);
self::assertStringStartsWith('<img', $img);
self::assertStringContainsString('class="flag"', $img);
self::assertStringContainsString('width="32" height="24"', $img);
self::assertStringContainsString('alt=""', $img);
self::assertStringEndsWith('>', $img);
}
}
#[Test]
public function flagImgHasFallbackForUnknownLocale(): void
{
$img = LocaleSwitcher::flagImg('xx');
self::assertStringStartsWith('<img', $img);
self::assertStringContainsString('class="flag"', $img);
// 1×1 transparent gif keeps the layout stable even when the
// locale code is not one of our four.
self::assertStringContainsString('data:image/gif', $img);
}
#[Test]
public function ariaLabelUsesCurrentLocaleName(): void
{
$htmlDe = (new LocaleSwitcher('de', '/'))->render();
$htmlEn = (new LocaleSwitcher('en', '/'))->render();
self::assertStringContainsString('aria-label="Sprache wählen"', $htmlDe);
self::assertStringContainsString('aria-label="Choose language"', $htmlEn);
}
#[Test]
public function triggerContainsCurrentLocaleFlag(): void
{
// The closed dropdown shows the current locale's flag in the trigger
$html = (new LocaleSwitcher('de', '/'))->render();
// The first <img class="flag"> in the document is the trigger and it
// must point at the German flag asset under /img/flags/.
$deFlag = LocaleSwitcher::flagImg('de');
$pos = strpos($html, $deFlag);
self::assertNotFalse($pos, 'expected German flag <img> in the trigger (first <img class="flag"> in document)');
self::assertStringContainsString('src="/img/flags/de.svg"', $deFlag);
}
}

198
tests/Core/I18nTest.php Normal file
View File

@@ -0,0 +1,198 @@
<?php
declare(strict_types=1);
namespace Tests\Core;
use App\Core\I18n;
use App\Core\Locale;
use PHPUnit\Framework\TestCase;
class I18nTest extends TestCase
{
/** @var string */
private string $tmpDir;
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir() . '/i18n-test-' . bin2hex(random_bytes(4));
mkdir($this->tmpDir);
I18n::setLocalesPath($this->tmpDir);
I18n::flushCache();
}
protected function tearDown(): void
{
I18n::flushCache();
I18n::setLocalesPath(dirname(__DIR__, 2) . '/app/Locales');
if (is_dir($this->tmpDir)) {
foreach (glob($this->tmpDir . '/*') as $f) {
@unlink($f);
}
@rmdir($this->tmpDir);
}
}
private function writeLocale(string $code, array $data): void
{
$content = '<?php return ' . var_export($data, true) . ';';
file_put_contents($this->tmpDir . '/' . $code . '.php', $content);
}
// ──────────────────────────────────────────────
// t(): basic lookup
// ──────────────────────────────────────────────
public function testReturnsKeyWhenNoLocalesExist(): void
{
$this->assertSame('missing.key', I18n::t('missing.key'));
}
public function testReturnsKeyWhenLocaleFileMissing(): void
{
// Only DE file exists
$this->writeLocale('de', ['hello' => 'Hallo']);
$this->assertSame('missing.key', I18n::t('missing.key', [], 'en'));
}
public function testReturnsKeyWhenKeyMissingInAllLocales(): void
{
$this->writeLocale('de', ['hello' => 'Hallo']);
$this->writeLocale('en', ['other' => 'Other']);
$this->assertSame('greeting', I18n::t('greeting', [], 'en'));
}
public function testFallsBackToDeWhenKeyMissingInCurrentLocale(): void
{
$this->writeLocale('de', ['nav.home' => 'Start']);
$this->writeLocale('en', ['other.key' => 'Other']);
$this->assertSame('Start', I18n::t('nav.home', [], 'en'));
}
public function testReturnsValueInCurrentLocale(): void
{
$this->writeLocale('de', ['greeting' => 'Hallo']);
$this->writeLocale('en', ['greeting' => 'Hello']);
$this->assertSame('Hallo', I18n::t('greeting', [], 'de'));
$this->assertSame('Hello', I18n::t('greeting', [], 'en'));
}
// ──────────────────────────────────────────────
// t(): placeholders
// ──────────────────────────────────────────────
public function testInterpolatesPlaceholders(): void
{
$this->writeLocale('de', ['welcome' => 'Willkommen, {name}!']);
$this->assertSame(
'Willkommen, Martin!',
I18n::t('welcome', ['name' => 'Martin'], 'de')
);
}
public function testInterpolatesMultiplePlaceholders(): void
{
$this->writeLocale('de', ['mail' => '{greeting}, deine Bestellung #{order} ist da.']);
$this->assertSame(
'Hi, deine Bestellung #42 ist da.',
I18n::t('mail', ['greeting' => 'Hi', 'order' => '42'], 'de')
);
}
public function testLeavesUnreplacedPlaceholdersAlone(): void
{
$this->writeLocale('de', ['x' => 'Hallo {name}']);
$this->assertSame('Hallo {name}', I18n::t('x', [], 'de'));
}
// ──────────────────────────────────────────────
// t(): default locale behavior
// ──────────────────────────────────────────────
public function testDefaultsToDeLocaleWhenNoneSpecified(): void
{
$this->writeLocale('de', ['greeting' => 'Hallo']);
$this->assertSame('Hallo', I18n::t('greeting'));
}
public function testRejectsUnsupportedLocaleAndReturnsKey(): void
{
$this->writeLocale('de', ['greeting' => 'Hallo']);
$this->assertSame('greeting', I18n::t('greeting', [], 'fr'));
}
// ──────────────────────────────────────────────
// tn(): plural variants (MVP: {n} interpolation)
// ──────────────────────────────────────────────
public function testTnPicksSingularForOne(): void
{
$this->writeLocale('de', [
'room.singular' => '1 Zimmer',
'room.plural' => '{n} Zimmer',
]);
$this->assertSame('1 Zimmer', I18n::tn('room.singular', 'room.plural', 1, [], 'de'));
}
public function testTnPicksPluralForOtherNumbers(): void
{
$this->writeLocale('de', [
'room.singular' => '1 Zimmer',
'room.plural' => '{n} Zimmer',
]);
$this->assertSame('6 Zimmer', I18n::tn('room.singular', 'room.plural', 6, [], 'de'));
$this->assertSame('0 Zimmer', I18n::tn('room.singular', 'room.plural', 0, [], 'de'));
}
// ──────────────────────────────────────────────
// has()
// ──────────────────────────────────────────────
public function testHasReturnsTrueForExistingKey(): void
{
$this->writeLocale('de', ['greeting' => 'Hallo']);
$this->writeLocale('en', ['greeting' => 'Hello']);
$this->assertTrue(I18n::has('greeting', 'en'));
$this->assertTrue(I18n::has('greeting', 'de'));
}
public function testHasReturnsTrueForFallbackKey(): void
{
$this->writeLocale('de', ['only_de' => 'Nur DE']);
$this->assertTrue(I18n::has('only_de', 'en'));
}
public function testHasReturnsFalseForMissingKey(): void
{
$this->writeLocale('de', ['x' => 'X']);
$this->assertFalse(I18n::has('nope', 'de'));
}
// ──────────────────────────────────────────────
// Caching
// ──────────────────────────────────────────────
public function testCacheSurvivesAcrossCalls(): void
{
$this->writeLocale('de', ['k' => 'v1']);
$this->assertSame('v1', I18n::t('k', [], 'de'));
// Mutate the file — cached value should still be returned
$this->writeLocale('de', ['k' => 'v2']);
$this->assertSame('v1', I18n::t('k', [], 'de'));
// Flush — now we see the new value
I18n::flushCache();
$this->assertSame('v2', I18n::t('k', [], 'de'));
}
public function testFlushCacheClearsAllLocales(): void
{
$this->writeLocale('de', ['k' => 'de-v']);
$this->writeLocale('en', ['k' => 'en-v']);
I18n::t('k', [], 'en');
I18n::flushCache();
$this->writeLocale('en', ['k' => 'en-v2']);
$this->assertSame('en-v2', I18n::t('k', [], 'en'));
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace Tests\Core;
use App\Core\I18n;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Guards against the most common i18n regression: a developer adds a key
* to one Locale file and forgets the other three. CI must fail loudly.
*/
final class LocaleConsistencyTest extends TestCase
{
/** @return array<string, array{string}> */
public static function localeProvider(): array
{
return [
'de' => ['de'],
'en' => ['en'],
'uk' => ['uk'],
'ru' => ['ru'],
];
}
#[Test]
public function allFourLocaleFilesLoadAndAreArrays(): void
{
foreach (self::localeProvider() as [$locale]) {
I18n::flushCache();
$data = require __DIR__ . '/../../app/Locales/' . $locale . '.php';
self::assertIsArray($data, "Locale file {$locale}.php must return an array");
self::assertNotEmpty($data, "Locale file {$locale}.php must not be empty");
}
}
#[Test]
public function everyLocaleHasExactlyTheSameKeySet(): void
{
$keysByLocale = [];
foreach (self::localeProvider() as [$locale]) {
$keysByLocale[$locale] = array_keys(require __DIR__ . '/../../app/Locales/' . $locale . '.php');
sort($keysByLocale[$locale]);
}
$reference = $keysByLocale['de'];
foreach (['en', 'uk', 'ru'] as $locale) {
$missing = array_diff($reference, $keysByLocale[$locale]);
$extra = array_diff($keysByLocale[$locale], $reference);
self::assertSame(
[],
$missing,
"Locale '{$locale}' is missing keys: " . implode(', ', $missing)
);
self::assertSame(
[],
$extra,
"Locale '{$locale}' has extra keys not in DE: " . implode(', ', $extra)
);
}
}
#[Test]
public function noTranslationValueIsEmpty(): void
{
foreach (self::localeProvider() as [$locale]) {
$data = require __DIR__ . '/../../app/Locales/' . $locale . '.php';
foreach ($data as $key => $value) {
self::assertIsString($value, "{$locale}.{$key} must be a string");
self::assertNotSame('', trim($value), "{$locale}.{$key} must not be empty");
}
}
}
#[Test]
#[DataProvider('localeProvider')]
public function everyTranslationIsValidUtf8(string $locale): void
{
$data = require __DIR__ . '/../../app/Locales/' . $locale . '.php';
foreach ($data as $key => $value) {
self::assertTrue(
mb_check_encoding($value, 'UTF-8'),
"{$locale}.{$key} contains invalid UTF-8"
);
}
}
}

184
tests/Core/LocaleTest.php Normal file
View File

@@ -0,0 +1,184 @@
<?php
declare(strict_types=1);
namespace Tests\Core;
use App\Core\Locale;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class LocaleTest extends TestCase
{
// ──────────────────────────────────────────────
// resolve(): priority order
// ──────────────────────────────────────────────
public function testResolveReturnsDefaultWhenNoSignals(): void
{
$this->assertSame('de', Locale::resolve());
}
public function testQueryParamWinsOverCookieAndHeader(): void
{
$this->assertSame('en', Locale::resolve('en', 'ru', 'uk'));
}
public function testCookieWinsOverHeader(): void
{
$this->assertSame('ru', Locale::resolve(null, 'ru', 'en'));
}
public function testHeaderUsedWhenNoQueryOrCookie(): void
{
$this->assertSame('en', Locale::resolve(null, null, 'en-US,de;q=0.9'));
}
public function testFallsBackToDefaultWhenHeaderDoesNotMatch(): void
{
$this->assertSame('de', Locale::resolve(null, null, 'fr-FR,it-IT'));
}
public function testInvalidQueryParamIsSkippedAndCookieWins(): void
{
// Invalid query (e.g. 'fr' which is not supported) is treated as
// "no signal" from that source — we fall through to the next source.
$this->assertSame('en', Locale::resolve('fr', 'en', 'en'));
}
public function testEmptyStringsAreTreatedAsNoSignal(): void
{
$this->assertSame('en', Locale::resolve('', '', 'en'));
}
// ──────────────────────────────────────────────
// isSupported()
// ──────────────────────────────────────────────
#[DataProvider('provideSupportedLocales')]
public function testIsSupportedReturnsTrueForKnownLocales(string $code): void
{
$this->assertTrue(Locale::isSupported($code));
}
#[DataProvider('provideUnsupportedLocales')]
public function testIsSupportedReturnsFalseForUnknownLocales(string $code): void
{
$this->assertFalse(Locale::isSupported($code));
}
public static function provideSupportedLocales(): array
{
return [
'german' => ['de'],
'uk-english' => ['en'],
'ukrainian' => ['uk'],
'russian' => ['ru'],
];
}
public static function provideUnsupportedLocales(): array
{
return [
'french' => ['fr'],
'empty' => [''],
'upper' => ['DE'],
'region' => ['de-DE'],
'wildcard' => ['*'],
'garbage' => ['xx'],
];
}
// ──────────────────────────────────────────────
// parseAcceptLanguage()
// ──────────────────────────────────────────────
public function testParseAcceptLanguageReturnsEmptyForEmptyHeader(): void
{
$this->assertSame([], Locale::parseAcceptLanguage(''));
}
public function testParseAcceptLanguageHandlesSingleTag(): void
{
$this->assertSame(['en'], Locale::parseAcceptLanguage('en'));
}
public function testParseAcceptLanguageSortsByQValue(): void
{
$this->assertSame(
['ru', 'en', 'de'],
Locale::parseAcceptLanguage('de;q=0.5,en;q=0.8,ru;q=0.9')
);
}
public function testParseAcceptLanguageDefaultsQTo1(): void
{
$this->assertSame(['de', 'en'], Locale::parseAcceptLanguage('de,en;q=0.5'));
}
public function testParseAcceptLanguageDropsQ0(): void
{
// 'en;q=0' is explicitly forbidden by the client → drop it.
// 'de;q=1' is the only one left.
$this->assertSame(['de'], Locale::parseAcceptLanguage('en;q=0,de;q=1'));
}
public function testParseAcceptLanguageStripsBcp47Region(): void
{
$this->assertSame(['en', 'de'], Locale::parseAcceptLanguage('en-US,de-DE'));
}
public function testParseAcceptLanguageDropsWildcard(): void
{
$this->assertSame([], Locale::parseAcceptLanguage('*'));
// Wildcard plus q=0 → nothing to use
$this->assertSame([], Locale::parseAcceptLanguage('*,en;q=0'));
}
public function testParseAcceptLanguageIsStableForEqualQValues(): void
{
$this->assertSame(
['en', 'de', 'uk'],
Locale::parseAcceptLanguage('en;q=0.8,de;q=0.8,uk;q=0.8')
);
}
public function testParseAcceptLanguageHandlesWhitespace(): void
{
$this->assertSame(['en', 'de'], Locale::parseAcceptLanguage(' en , de ; q=0.5 '));
}
// ──────────────────────────────────────────────
// toOgLocale()
// ──────────────────────────────────────────────
public function testToOgLocaleMapsToBcp47(): void
{
$this->assertSame('de_DE', Locale::toOgLocale('de'));
$this->assertSame('en_GB', Locale::toOgLocale('en'));
$this->assertSame('uk_UA', Locale::toOgLocale('uk'));
$this->assertSame('ru_RU', Locale::toOgLocale('ru'));
}
public function testToOgLocaleFallsBackToDeForUnknown(): void
{
$this->assertSame('de_DE', Locale::toOgLocale('fr'));
}
// ──────────────────────────────────────────────
// hreflangAlternates()
// ──────────────────────────────────────────────
public function testHreflangAlternatesBuildsFullSet(): void
{
$alts = Locale::hreflangAlternates('/');
$this->assertCount(4, $alts);
$locales = array_column($alts, 'locale');
$this->assertSame(['de', 'en', 'uk', 'ru'], $locales);
$en = array_values(array_filter($alts, static fn ($a) => $a['locale'] === 'en'))[0];
$this->assertSame('en-GB', $en['hreflang']);
$this->assertStringContainsString('?lang=en', $en['href']);
}
}

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
E2E test: language switcher flow on the landing page.
Verifies that clicking a flag in the locale switcher changes the active
language and the page hero content updates accordingly. Runs against a
PHP dev-server instance expected at http://127.0.0.1:8081.
Usage: python3 tests/E2E/language_flow.py
EXIT 0 on success, non-zero on failure.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
# Resolve the playwright path without requiring a global install.
try:
from playwright.sync_api import sync_playwright, TimeoutError as PlaywrightTimeoutError
except ImportError:
sys.stderr.write("playwright not installed; skipping E2E\n")
sys.exit(0)
BASE_URL = os.environ.get("BASE_URL", "http://127.0.0.1:8081")
# Per-locale hero line 1, kept in sync with app/Locales/*.php.
# The E2E test reads the title from the H1 instead of hardcoding so it
# only needs to be kept in sync with the EN locale (the default fallback
# shown when navigating to the root).
EXPECTED = {
"de": ("Großzügiges", "Einfamilienhaus"),
"en": ("Spacious", "Detached house"),
"uk": ("Просторий", "Приватний будинок"),
"ru": ("Просторный", "Частный дом"),
}
def check_locale(page, code: str) -> None:
line1, line2 = EXPECTED[code]
h1 = page.locator("h1.hero-h1")
h1.wait_for(state="visible", timeout=5_000)
text = h1.inner_text(timeout=5_000)
if line1 not in text or line2 not in text:
raise AssertionError(
f"[{code}] expected hero to contain {line1!r} + {line2!r}, got: {text!r}"
)
# <html lang> must reflect the active locale.
html_lang = page.evaluate("document.documentElement.lang")
if html_lang != code:
raise AssertionError(
f"[{code}] expected <html lang>={code!r}, got {html_lang!r}"
)
def main() -> int:
with sync_playwright() as pw:
# Use system Chrome (Playwright's bundled Chromium does not install
# on Ubuntu 26.04). Pass --no-sandbox since this runs as root in CI.
browser = pw.chromium.launch(
executable_path=os.environ.get("CHROME_BIN", "/usr/bin/google-chrome"),
headless=True,
args=["--no-sandbox", "--disable-gpu"],
)
context = browser.new_context(accept_downloads=False)
page = context.new_page()
try:
# Pin the initial locale via query-string so the test is not
# at the mercy of the browser's Accept-Language header.
page.goto(f"{BASE_URL}/?lang=de", wait_until="domcontentloaded", timeout=10_000)
# Default locale is DE (Locale::DEFAULT).
check_locale(page, "de")
for code in ("en", "uk", "ru", "de"):
# Locale switcher links are <a> with hreflang equal to the locale code.
link = page.locator(f'a[hreflang="{code}"]').first
link.wait_for(state="visible", timeout=5_000)
link.click()
# Wait for the page to reload / navigate.
page.wait_for_load_state("domcontentloaded", timeout=10_000)
check_locale(page, code)
print(f" ✓ locale={code} hero verified")
print("OK: language flow E2E passed for all 4 locales")
return 0
except (PlaywrightTimeoutError, AssertionError) as exc:
print(f"FAIL: {exc}", file=sys.stderr)
return 1
finally:
context.close()
browser.close()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,223 @@
<?php
declare(strict_types=1);
namespace Tests\Integration;
use App\Controllers\HomeController;
use App\Controllers\LocaleSwitcher;
use App\Core\I18n;
use App\Core\Locale;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Integration test: render the home view in every supported locale and
* assert the rendered HTML matches the locale's translation strings.
*
* Acts as a render-snapshot smoke test for the i18n feature and a
* regression guard against hardcoded DE strings slipping into
* non-DE views.
*/
final class RenderTest extends TestCase
{
/**
* Build the local variable scope expected by `app/views/home/index.php`
* and the layout, and capture the rendered output.
*
* @return array{0:string,1:string} [html, rendered <html lang> value]
*/
private function renderHomeIn(string $locale, array $formErrors = [], array $formFieldErrors = [], bool $formSuccess = false): array
{
// Sanity: the locale must be one of the supported ones.
self::assertTrue(Locale::isSupported($locale), "Unsupported locale for render: $locale");
$t = static function (string $key) use ($locale): string {
return I18n::t($key, [], $locale);
};
$formData = [
'fname' => '',
'lname' => '',
'email' => '',
'phone' => '',
'interest' => 'visit',
'message' => '',
];
$interestKeys = [
'visit' => 'form.interest.visit',
'info' => 'form.interest.info',
'apply' => 'form.interest.apply',
];
$escapeContactValue = static fn(string $value): string
=> htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
// Empty honeypot/csrf/session.
$_SESSION = [
'csrf_token' => 'csrf-test-token',
'form_start_time' => time(),
];
$viewPath = dirname(__DIR__, 2) . '/app/views/home/index.php';
self::assertFileExists($viewPath);
ob_start();
include $viewPath;
$body = (string) ob_get_clean();
// Layout prefix: open <html lang>...</html> via a tiny shim.
$layout = sprintf(
'<!doctype html><html lang="%s"><head><meta charset="utf-8"><title>%s</title></head><body>%s</body></html>',
htmlspecialchars($locale, ENT_QUOTES),
htmlspecialchars($t('hero.h1.line1') . ' ' . $t('hero.h1.line2'), ENT_QUOTES),
$body,
);
return [$layout, $locale];
}
/**
* Each locale's home render must contain the locale-specific
* translation keys (and they must be the locale's expected text).
*/
#[Test]
#[DataProvider('supportedLocaleProvider')]
public function homeRendersLocaleSpecificHeroCopy(string $locale): void
{
[$html] = $this->renderHomeIn($locale);
// Per-locale hero h1 line 1 — strong, locale-specific token.
$expectedLine1 = I18n::t('hero.h1.line1', [], $locale);
self::assertNotSame('hero.h1.line1', $expectedLine1, "Translation missing for hero.h1.line1 in $locale");
self::assertStringContainsString(
$expectedLine1,
$html,
"Expected hero line 1 ($expectedLine1) not rendered in $locale"
);
// Hero tag
$expectedTag = I18n::t('hero.tag', [], $locale);
self::assertStringContainsString($expectedTag, $html, "Hero tag not rendered in $locale");
}
/**
* `<html lang="…">` must match the active locale so screen readers,
* search engines, and the browser's auto-translate UI behave.
*/
#[Test]
#[DataProvider('supportedLocaleProvider')]
public function htmlRootLangAttributeMatchesActiveLocale(string $locale): void
{
[$html] = $this->renderHomeIn($locale);
self::assertMatchesRegularExpression(
'~<html\s+lang="[^"]*' . preg_quote($locale, '~') . '[^"]*"~',
$html,
"Layout must bind <html lang> to $locale"
);
}
/**
* Regression guard: a hardcoded DE string that's not a proper noun
* (e.g. "Einfamilienhaus") must NOT appear in the EN/UK/RU render.
*/
#[Test]
#[DataProvider('nonGermanLocaleProvider')]
public function nonGermanRenderDoesNotLeakGermanCopy(string $locale): void
{
[$html] = $this->renderHomeIn($locale);
$germanOnly = [
'Einfamilienhaus', // hero.h1.line2 DE
'Großzügiges', // hero.h1.line1 DE (with ß)
'Entdecken', // hero.discover DE
'Galerie', // nav.gallery DE
];
foreach ($germanOnly as $needle) {
self::assertStringNotContainsString(
$needle,
$html,
"German copy \"$needle\" leaked into $locale render"
);
}
}
/**
* Switcher widget: with the active locale, that locale's flag/link
* must carry `aria-current="true"` per a11y contract.
*/
#[Test]
#[DataProvider('supportedLocaleProvider')]
public function localeSwitcherMarksActiveLocaleWithAriaCurrent(string $locale): void
{
$switcherHtml = (new LocaleSwitcher($locale, '/'))->render();
self::assertStringContainsString('aria-current="true"', $switcherHtml, "Active locale $locale should have aria-current");
// The active locale's link must point at itself (relative path stays on the page).
self::assertMatchesRegularExpression(
'~aria-current="true"[^>]*>.*?'
. preg_quote(I18n::t('locale.' . $locale, [], $locale), '~')
. '~s',
$switcherHtml,
"Active locale $locale not properly labelled in switcher"
);
}
/**
* All four locales should produce roughly the same DOM skeleton
* (same section ids, same form structure) — translation is
* content swap, not structural drift.
*/
#[Test]
public function homeDomSkeletonIsStableAcrossLocales(): void
{
$skeletons = [];
foreach (Locale::SUPPORTED as $locale) {
[$html] = $this->renderHomeIn($locale);
// Pull out section/landmark ids + a couple of structural tags.
preg_match_all(
'~<(?:section|main|header|footer|nav|aside|form)\b[^>]*(?:\bid="([^"]+)")?~',
$html,
$matches
);
$skeletons[$locale] = $matches[0];
}
// All four skeletons must have the same number of structural tags.
$counts = array_map('count', $skeletons);
$unique = array_unique($counts);
self::assertCount(1, $unique, 'DOM skeleton size differs across locales: ' . json_encode($counts));
}
public static function supportedLocaleProvider(): array
{
$out = [];
foreach (Locale::SUPPORTED as $locale) {
$out[$locale] = [$locale];
}
return $out;
}
public static function nonGermanLocaleProvider(): array
{
$out = [];
foreach (Locale::SUPPORTED as $locale) {
if ($locale === Locale::DEFAULT) {
continue;
}
$out[$locale] = [$locale];
}
return $out;
}
protected function setUp(): void
{
// Make sure the I18n cache is fresh per test.
I18n::flushCache();
parent::setUp();
}
}

View File

@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace Tests\Views;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Integration test: render the full home view with various
* form-error scenarios and verify the emitted HTML exposes
* accessibility hooks (aria-invalid, aria-describedby,
* role/aria-live, etc.).
*
* Strategy: include the view file directly after populating the
* locals it expects. We bypass the controller and session start
* to keep the test self-contained.
*/
final class HomeViewA11yTest extends TestCase
{
/** @var array<string, string> */
private array $translations;
/** @var array<string, mixed> */
private array $session = [];
protected function setUp(): void
{
// Use the real de.php translations so we never drift from
// production copy. Anything missing returns the key in
// brackets (matches I18n::t()'s dev fallback).
$de = require dirname(__DIR__, 2) . '/app/Locales/de.php';
$this->translations = $this->flatten($de);
$this->session = [
'csrf_token' => str_repeat('a', 64),
];
}
/**
* Flatten a nested translation array to dot.notation keys.
*
* @param array<string,mixed> $arr
* @return array<string,string>
*/
private function flatten(array $arr, string $prefix = ''): array
{
$out = [];
foreach ($arr as $key => $val) {
$full = $prefix === '' ? (string) $key : $prefix . '.' . $key;
if (is_array($val)) {
$out += $this->flatten($val, $full);
} else {
$out[$full] = (string) $val;
}
}
return $out;
}
#[Test]
public function formResultHasAriaLivePolite(): void
{
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
self::assertStringContainsString('id="form-result"', $html);
self::assertStringContainsString('aria-live="polite"', $html);
self::assertStringContainsString('role="status"', $html);
}
#[Test]
public function errorListHasRoleAlert(): void
{
$html = $this->renderHomeView(
formErrors: ['form.error.csrf'],
formFieldErrors: [],
formSuccess: false,
);
self::assertStringContainsString('class="form-errors"', $html);
self::assertStringContainsString('role="alert"', $html);
}
#[Test]
public function fieldErrorRendersAriaInvalidAndDescribedBy(): void
{
$html = $this->renderHomeView(
formErrors: [],
formFieldErrors: [
'fname' => ['form.error.fname_required'],
'email' => ['form.error.email_invalid'],
'message' => ['form.error.message_required'],
],
formSuccess: false,
);
self::assertStringContainsString('aria-invalid="true"', $html);
self::assertStringContainsString('aria-describedby="err-fname"', $html);
self::assertStringContainsString('aria-describedby="err-email"', $html);
self::assertStringContainsString('aria-describedby="err-message"', $html);
self::assertStringContainsString('id="err-fname" class="form-field-error"', $html);
self::assertStringContainsString('Bitte geben Sie Ihren Vornamen an.', $html);
self::assertStringContainsString('id="err-email" class="form-field-error"', $html);
self::assertStringContainsString('Bitte geben Sie eine gültige E-Mail-Adresse an.', $html);
self::assertStringContainsString('id="err-message" class="form-field-error"', $html);
}
#[Test]
public function fieldsWithoutErrorsDoNotHaveAriaInvalid(): void
{
$html = $this->renderHomeView(
formErrors: [],
formFieldErrors: ['fname' => ['form.error.fname_required']],
formSuccess: false,
);
$count = substr_count($html, 'aria-invalid="true"');
self::assertSame(1, $count, 'Only fields with errors should carry aria-invalid');
self::assertStringNotContainsString('aria-describedby="err-lname"', $html);
self::assertStringNotContainsString('aria-describedby="err-email"', $html);
self::assertStringNotContainsString('aria-describedby="err-message"', $html);
}
#[Test]
public function successMessageRendersInsideFormResult(): void
{
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: true);
self::assertStringContainsString('class="form-success"', $html);
self::assertStringContainsString('Vielen Dank für Ihre Anfrage!', $html);
self::assertStringContainsString('Wir haben Ihre Nachricht erhalten', $html);
}
#[Test]
public function honeypotFieldIsVisuallyHidden(): void
{
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
self::assertStringContainsString('class="hp-field"', $html);
self::assertStringContainsString('aria-hidden="true"', $html);
self::assertStringContainsString('tabindex="-1"', $html);
self::assertStringContainsString('autocomplete="off"', $html);
}
#[Test]
public function csrfTokenIsEmbeddedAsHiddenField(): void
{
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
self::assertStringContainsString('name="csrf_token"', $html);
self::assertStringContainsString('value="' . str_repeat('a', 64) . '"', $html);
}
#[Test]
public function submitButtonHasExpectedClass(): void
{
$html = $this->renderHomeView(formErrors: [], formFieldErrors: [], formSuccess: false);
self::assertStringContainsString('class="form-submit"', $html);
}
/**
* @param list<string> $formErrors
* @param array<string, list<string>> $formFieldErrors
*/
private function renderHomeView(array $formErrors, array $formFieldErrors, bool $formSuccess): string
{
$locale = 'de';
$t = function (string $key) use ($locale): string {
return $this->translations[$key] ?? '[' . $key . ']';
};
$formData = [
'fname' => 'Maria',
'lname' => '',
'email' => 'not-an-email',
'phone' => '',
'interest' => 'visit',
'message' => '',
];
$escapeContactValue = static fn(string $value): string
=> htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
// Provide $_SESSION to the view scope via a global.
$_SESSION = $this->session;
$viewPath = dirname(__DIR__, 2) . '/app/views/home/index.php';
self::assertFileExists($viewPath);
ob_start();
try {
include $viewPath;
} catch (\Throwable $e) {
ob_end_clean();
self::fail('View rendering threw: ' . $e->getMessage() . "\n" . $e->getTraceAsString());
}
return (string) ob_get_clean();
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Tests\Views;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
/**
* Static a11y checks for the shared layout
* (`app/views/layouts/main.php`). We verify the layout's *source*
* carries the right ARIA / landmark attributes rather than spinning
* up the full render (which would need nav, OG, structured-data,
* server-vars, and a dozen more locals). The home view is tested
* for content-level a11y in `HomeViewA11yTest`.
*/
final class LayoutA11yTest extends TestCase
{
private string $layoutSource;
protected function setUp(): void
{
$path = dirname(__DIR__, 2) . '/app/views/layouts/main.php';
self::assertFileExists($path);
$this->layoutSource = (string) file_get_contents($path);
self::assertNotEmpty($this->layoutSource);
}
#[Test]
public function htmlRootCarriesLangAttribute(): void
{
// The layout emits <html lang="..."> with the value rendered by PHP from
// the $locale variable (not literally `$locale` between quotes).
// We assert the source binds lang to the $locale variable inside PHP output.
self::assertStringContainsString('<html lang="', $this->layoutSource);
self::assertStringContainsString('$locale', $this->layoutSource);
// Make sure $locale actually flows into the lang attribute (basic
// co-occurrence check on the same <html ...> opening tag).
self::assertMatchesRegularExpression(
'~<html\b[^>]*\blang=[^>]*\$locale~s',
$this->layoutSource,
'Layout must bind <html lang> to the current locale'
);
}
#[Test]
public function skipLinkPointsAtMain(): void
{
self::assertStringContainsString('class="skip-link"', $this->layoutSource);
self::assertStringContainsString('href="#main"', $this->layoutSource);
}
#[Test]
public function mainLandmarkHasIdAndAriaLabel(): void
{
self::assertMatchesRegularExpression(
'/<main\b[^>]*\bid="main"/',
$this->layoutSource
);
self::assertMatchesRegularExpression(
'/<main\b[^>]*\baria-label=/',
$this->layoutSource
);
}
#[Test]
public function navLandmarkHasAriaLabel(): void
{
self::assertMatchesRegularExpression(
'/<nav\b[^>]*\baria-label=/',
$this->layoutSource
);
}
#[Test]
public function footerHasRoleAndLabel(): void
{
self::assertMatchesRegularExpression(
'/<footer\b[^>]*\baria-label=/',
$this->layoutSource
);
}
#[Test]
public function lightboxIsADialogModal(): void
{
self::assertStringContainsString('id="lightbox"', $this->layoutSource);
self::assertStringContainsString('role="dialog"', $this->layoutSource);
self::assertStringContainsString('aria-modal="true"', $this->layoutSource);
}
#[Test]
public function lightboxImageCarriesAltAttribute(): void
{
self::assertMatchesRegularExpression(
'/<img\b[^>]*\bid="lightboxImg"[^>]*\balt=/',
$this->layoutSource
);
}
#[Test]
public function localeSwitcherHostIsAriaLabeled(): void
{
// The LocaleSwitcher widget uses aria-label on its <ul>.
self::assertStringContainsString('aria-label="', $this->layoutSource);
}
#[Test]
public function mobileNavToggleIsKeyboardAccessible(): void
{
self::assertMatchesRegularExpression(
'/<button\b[^>]*\bclass="nav-hamburger"/',
$this->layoutSource
);
self::assertStringContainsString('aria-expanded=', $this->layoutSource);
self::assertStringContainsString('aria-controls=', $this->layoutSource);
}
}