Multi-Language MVP: 4 Locales (DE/EN/UK/RU) mit SSR + UI + Tests #79

Merged
greggy merged 27 commits from feature/multilanguage-mvp into main 2026-06-05 23:49:39 +02:00
4 changed files with 315 additions and 118 deletions
Showing only changes of commit 7dd8023222 - Show all commits

View File

@@ -1,131 +1,123 @@
name: Deploy Feature Branch to Test name: Deploy Feature Branch to Test (haus.test.kies-media.de)
on: on:
push: push:
branches: branches:
- "feature/**" - "feature/**"
workflow_dispatch:
inputs:
ref:
description: "Branch or tag to deploy (default: HEAD)"
required: false
default: ""
jobs: 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: deploy:
name: Deploy to Test Environment name: Deploy to Test Environment
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [lint-php, lint-css, lint-html] concurrency:
container: group: deploy-test
volumes: cancel-in-progress: false
- /var/www/test/html:/deploy
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Show branch info - name: Setup SSH
run: | run: |
echo "=== Deploying branch: ${{ gitea.ref_name }} ===" mkdir -p ~/.ssh
echo "=== Commit: ${{ gitea.sha }} ===" echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
echo "=== By: ${{ gitea.actor }} ===" chmod 600 ~/.ssh/id_ed25519
echo "=== All lint checks passed ✅ ===" ssh-keyscan -H 188.245.242.194 >> ~/.ssh/known_hosts 2>/dev/null
date echo "✅ SSH key configured"
- name: Deploy to test environment - name: Verify SSH connectivity
run: | run: |
echo "Syncing files to test environment..." ssh -o StrictHostKeyChecking=yes -i ~/.ssh/id_ed25519 \
apt-get update -qq && apt-get install -y -qq rsync > /dev/null 2>&1 || true haustest@188.245.242.194 "echo 'SSH-OK as:' \$(whoami) 'on' \$(hostname)"
rsync -av --delete \ - name: Backup current test deployment
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
run: |
rsync -avz --delete \
--exclude='.git' \ --exclude='.git' \
--exclude='node_modules' \
--exclude='tests' \
--exclude='docs' \
--exclude='.gitea' \ --exclude='.gitea' \
--exclude='.husky' \
--exclude='.prettierrc' \
--exclude='.prettierignore' \
--exclude='.stylelintrc.json' \
--exclude='.htmlhintrc' \
--exclude='.gitignore' \
--exclude='.dockerignore' \
--exclude='.continue' \ --exclude='.continue' \
--exclude='.husky' \
--exclude='Dockerfile' \ --exclude='Dockerfile' \
--exclude='.dockerignore' \
--exclude='nginx.conf' \ --exclude='nginx.conf' \
--exclude='eslint.config.js' \ --exclude='eslint.config.js' \
--exclude='package.json' \ --exclude='package.json' \
--exclude='package-lock.json' \ --exclude='package-lock.json' \
--exclude='docs/' \ --exclude='phpunit.xml' \
--exclude='scripts' \
--exclude='AGENTS.md' \ --exclude='AGENTS.md' \
--exclude='README.md' \ --exclude='README.md' \
--exclude='scripts/' \ --exclude='CLAUDE.md' \
./ /deploy/ --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: Smoke test
- name: Set permissions
run: | run: |
chown -R 33:33 /deploy/ 2>/dev/null || true sleep 2
chmod -R 755 /deploy/ 2>/dev/null || true echo "--- HTTP status codes ---"
echo "✅ Permissions set" 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 - name: Deployment summary
if: always()
run: | run: |
echo "==========================================" echo "### 🚀 Deployment Summary" >> $GITHUB_STEP_SUMMARY
echo " 🚀 Deployment Summary" echo "" >> $GITHUB_STEP_SUMMARY
echo "==========================================" echo "- **Target:** https://haus.test.kies-media.de" >> $GITHUB_STEP_SUMMARY
echo " Branch: ${{ gitea.ref_name }}" echo "- **Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
echo " Commit: ${{ gitea.sha }}" echo "- **Commit:** \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY
echo " Target: http://178.104.150.0:6427/" echo "- **Server:** haustest@188.245.242.194" >> $GITHUB_STEP_SUMMARY
echo " Lint: ✅ All checks passed" echo "" >> $GITHUB_STEP_SUMMARY
echo " Time: $(date)" echo "**Review URL:** https://haus.test.kies-media.de" >> $GITHUB_STEP_SUMMARY
echo "=========================================="

View File

@@ -36,7 +36,8 @@ final class LocaleSwitcher
'UTF-8', 'UTF-8',
); );
$html = '<ul class="locale-switcher" role="list" aria-label="' . $ariaLabel . '">'; $html = '<div class="locale-switcher-wrapper">';
$html .= '<ul class="locale-switcher" role="list" aria-label="' . $ariaLabel . '">';
foreach (Locale::SUPPORTED as $code) { foreach (Locale::SUPPORTED as $code) {
$isCurrent = $code === $this->currentLocale; $isCurrent = $code === $this->currentLocale;
$name = htmlspecialchars( $name = htmlspecialchars(
@@ -74,6 +75,53 @@ final class LocaleSwitcher
} }
$html .= '</ul>'; $html .= '</ul>';
// Mobile dropdown — compact single-trigger switcher for narrow viewports
$currentName = htmlspecialchars(
I18n::t('locale.' . $this->currentLocale, [], $this->currentLocale),
ENT_QUOTES,
'UTF-8',
);
$currentCode = htmlspecialchars($this->currentLocale, ENT_QUOTES, 'UTF-8');
$currentFlag = self::flagSvg($this->currentLocale);
$html .= '<details class="locale-switcher-mobile">';
$html .= '<summary class="locale-switcher-mobile__trigger" aria-label="' . $ariaLabel . '">';
$html .= '<span class="locale-switcher-mobile__current" lang="' . $currentCode . '">';
$html .= $currentFlag;
$html .= '<span class="locale-switcher-mobile__current-code">' . strtoupper($currentCode) . '</span>';
$html .= '</span>';
$html .= '<span class="locale-switcher-mobile__caret" aria-hidden="true">▾</span>';
$html .= '</summary>';
$html .= '<ul class="locale-switcher-mobile__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::flagSvg($code);
$html .= '<li>';
if ($isCurrent) {
$html .= '<span class="locale-switcher-mobile__option is-current" aria-current="true" lang="' . $codeAttr . '">'
. $flag . '<span>' . $name . '</span></span>';
} else {
$url = '/locale?set=' . rawurlencode($code) . '&amp;return=' . rawurlencode($path);
$html .= '<a class="locale-switcher-mobile__option"'
. ' href="' . $url . '"'
. ' hreflang="' . $codeAttr . '"'
. ' lang="' . $codeAttr . '"'
. ' rel="alternate"'
. '>'
. $flag . '<span>' . $name . '</span></a>';
}
$html .= '</li>';
}
$html .= '</ul>';
$html .= '</details>';
$html .= '</div>';
return $html; return $html;
} }

View File

@@ -54,6 +54,7 @@ a:focus-visible {
--charcoal: #2e2b26; --charcoal: #2e2b26;
--accent: #8b6914; --accent: #8b6914;
--accent-light: #c49a2a; --accent-light: #c49a2a;
--accent-strong: #5a450d;
--white: #fdfcfa; --white: #fdfcfa;
} }
@@ -338,7 +339,7 @@ nav.scrolled .nav-hamburger span::after {
gap: 2.5rem; gap: 2.5rem;
align-items: center; align-items: center;
font-size: 0.82rem; font-size: 0.82rem;
color: rgb(255 255 255 / 60%); color: rgb(255 255 255 / 88%);
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -441,10 +442,10 @@ nav.scrolled .nav-hamburger span::after {
.section-eyebrow { .section-eyebrow {
font-size: 0.72rem; font-size: 0.72rem;
font-weight: 500; font-weight: 600;
letter-spacing: 0.2em; letter-spacing: 0.2em;
text-transform: uppercase; text-transform: uppercase;
color: var(--accent); color: var(--accent-strong);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@@ -812,15 +813,21 @@ nav.scrolled .nav-hamburger span::after {
font-size: 0.72rem; font-size: 0.72rem;
letter-spacing: 0.15em; letter-spacing: 0.15em;
text-transform: uppercase; text-transform: uppercase;
color: var(--stone); color: rgb(255 255 255 / 70%);
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.price-card.highlight .pc-label { .price-card .price-label {
display: block;
font-size: 0.72rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: rgb(255 255 255 / 70%); color: rgb(255 255 255 / 70%);
margin-bottom: 0.8rem;
} }
.pc-val { .price-card .price-value {
display: block;
font-family: "Cormorant Garamond", serif; font-family: "Cormorant Garamond", serif;
font-size: 2.8rem; font-size: 2.8rem;
font-weight: 600; font-weight: 600;
@@ -829,15 +836,37 @@ nav.scrolled .nav-hamburger span::after {
margin-bottom: 0.3rem; margin-bottom: 0.3rem;
} }
.pc-sub { .price-card .price-unit {
display: block;
font-size: 0.78rem; font-size: 0.78rem;
color: var(--stone); color: rgb(255 255 255 / 75%);
} }
.price-card.highlight .pc-sub { .price-card.highlight .price-label,
.price-card.highlight .price-unit {
color: rgb(255 255 255 / 85%);
}
.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: rgb(255 255 255 / 70%); color: rgb(255 255 255 / 70%);
} }
.pricing-section .rent-notes dd {
margin: 0;
color: var(--white);
font-size: 0.95rem;
}
.price-note { .price-note {
margin-top: 2.5rem; margin-top: 2.5rem;
padding-top: 2.5rem; padding-top: 2.5rem;
@@ -1210,6 +1239,24 @@ footer {
display: flex; display: flex;
} }
/* Logo: keep icon, hide text on small viewports */
.logo-text {
display: none;
}
/* Locale switcher: replace 4-flag list with single-trigger dropdown */
.locale-switcher {
display: none;
}
.locale-switcher-mobile {
display: block;
}
.locale-switcher-mobile[open] > .locale-switcher-mobile__menu {
display: block;
}
/* Mobile slide-down nav */ /* Mobile slide-down nav */
nav.mobile-open .nav-links { nav.mobile-open .nav-links {
display: flex; display: flex;
@@ -1324,14 +1371,16 @@ footer {
} }
} }
/* LOCALE SWITCHER (sub-Issue D) */ /* LOCALE SWITCHER (sub-Issue D) — desktop only */
.locale-switcher { @media (width > 900px) {
display: flex; .locale-switcher {
align-items: center; display: flex;
gap: 0.25rem; align-items: center;
list-style: none; gap: 0.25rem;
margin: 0; list-style: none;
padding: 0; margin: 0;
padding: 0;
}
} }
.locale-switcher__item { .locale-switcher__item {
@@ -1443,6 +1492,110 @@ nav.scrolled .locale-switcher__option.is-current {
border: 0; border: 0;
} }
/* MOBILE LOCALE SWITCHER (dropdown — defaults to hidden on desktop) */
@media (width > 900px) {
.locale-switcher-mobile {
display: none;
}
}
.locale-switcher-mobile {
position: relative;
}
.locale-switcher-mobile__trigger {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 0.7rem;
border: 1px solid rgb(0 0 0 / 12%);
border-radius: 6px;
background: rgb(255 255 255 / 95%);
cursor: pointer;
list-style: none;
min-height: 44px;
color: var(--dark);
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.04em;
}
.locale-switcher-mobile__trigger::-webkit-details-marker {
display: none;
}
.locale-switcher-mobile__current {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.locale-switcher-mobile__current-code {
letter-spacing: 0.05em;
}
.locale-switcher-mobile__caret {
font-size: 0.7rem;
transition: transform 0.2s ease;
}
.locale-switcher-mobile[open] .locale-switcher-mobile__caret {
transform: rotate(180deg);
}
.locale-switcher-mobile__menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 180px;
margin: 0;
padding: 0.4rem;
list-style: none;
background: var(--white);
border: 1px solid var(--warm);
border-radius: 8px;
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
z-index: 60;
display: none;
}
.locale-switcher-mobile__option {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.7rem;
border-radius: 4px;
text-decoration: none;
color: var(--dark);
font-size: 0.85rem;
min-height: 44px;
}
.locale-switcher-mobile__option.is-current {
background: var(--cream);
color: var(--accent-strong);
font-weight: 600;
}
.locale-switcher-mobile__option:hover,
.locale-switcher-mobile__option:focus-visible {
background: var(--warm);
outline: none;
}
nav.scrolled .locale-switcher-mobile__trigger {
background: var(--white);
color: var(--dark);
border-color: var(--warm);
}
nav:not(.scrolled) .locale-switcher-mobile__trigger {
background: rgb(255 255 255 / 12%);
color: var(--white);
border-color: rgb(255 255 255 / 30%);
backdrop-filter: blur(4px);
}
/* FORM FIELD ERRORS (sub-Issue E) */ /* FORM FIELD ERRORS (sub-Issue E) */
.form-field-error { .form-field-error {
margin: 0.375rem 0 0; margin: 0.375rem 0 0;

View File

@@ -24,6 +24,7 @@ final class LocaleSwitcherTest extends TestCase
{ {
$html = (new LocaleSwitcher('en', '/'))->render(); $html = (new LocaleSwitcher('en', '/'))->render();
self::assertStringContainsString('<ul class="locale-switcher"', $html); self::assertStringContainsString('<ul class="locale-switcher"', $html);
self::assertStringContainsString('<details class="locale-switcher-mobile"', $html);
// The 3 inactive locales render as <a hreflang="..">. The active // The 3 inactive locales render as <a hreflang="..">. The active
// locale renders as <span lang=".."> (no hreflang). Together all // locale renders as <span lang=".."> (no hreflang). Together all
@@ -34,7 +35,10 @@ final class LocaleSwitcherTest extends TestCase
"locale '$code' is missing from switcher", "locale '$code' is missing from switcher",
); );
} }
self::assertSame(4, substr_count($html, 'class="flag"'), 'expected 4 flag SVGs'); // Both the desktop list and the mobile dropdown render a flag per locale
self::assertSame(4, substr_count($html, 'class="locale-switcher__option'), 'expected 4 desktop options');
self::assertSame(4, substr_count($html, 'class="locale-switcher-mobile__option'), 'expected 4 mobile options');
self::assertSame(9, substr_count($html, 'class="flag"'), 'expected 9 flag SVGs (4 desktop + 4 mobile menu + 1 mobile trigger)');
} }
#[Test] #[Test]