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:
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
run: |
echo "=== Deploying branch: ${{ gitea.ref_name }} ==="
echo "=== Commit: ${{ gitea.sha }} ==="
echo "=== By: ${{ gitea.actor }} ==="
echo "=== All lint checks passed ✅ ==="
date
mkdir -p ~/.ssh
echo "${{ secrets.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
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
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='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
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

View File

@@ -36,7 +36,8 @@ final class LocaleSwitcher
'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) {
$isCurrent = $code === $this->currentLocale;
$name = htmlspecialchars(
@@ -74,6 +75,53 @@ final class LocaleSwitcher
}
$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;
}

View File

@@ -54,6 +54,7 @@ a:focus-visible {
--charcoal: #2e2b26;
--accent: #8b6914;
--accent-light: #c49a2a;
--accent-strong: #5a450d;
--white: #fdfcfa;
}
@@ -338,7 +339,7 @@ nav.scrolled .nav-hamburger span::after {
gap: 2.5rem;
align-items: center;
font-size: 0.82rem;
color: rgb(255 255 255 / 60%);
color: rgb(255 255 255 / 88%);
letter-spacing: 0.05em;
}
@@ -441,10 +442,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;
}
@@ -812,15 +813,21 @@ nav.scrolled .nav-hamburger span::after {
font-size: 0.72rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--stone);
color: rgb(255 255 255 / 70%);
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%);
margin-bottom: 0.8rem;
}
.pc-val {
.price-card .price-value {
display: block;
font-family: "Cormorant Garamond", serif;
font-size: 2.8rem;
font-weight: 600;
@@ -829,15 +836,37 @@ 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: 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%);
}
.pricing-section .rent-notes dd {
margin: 0;
color: var(--white);
font-size: 0.95rem;
}
.price-note {
margin-top: 2.5rem;
padding-top: 2.5rem;
@@ -1210,6 +1239,24 @@ footer {
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 */
nav.mobile-open .nav-links {
display: flex;
@@ -1324,14 +1371,16 @@ footer {
}
}
/* LOCALE SWITCHER (sub-Issue D) */
.locale-switcher {
/* LOCALE SWITCHER (sub-Issue D) — desktop only */
@media (width > 900px) {
.locale-switcher {
display: flex;
align-items: center;
gap: 0.25rem;
list-style: none;
margin: 0;
padding: 0;
}
}
.locale-switcher__item {
@@ -1443,6 +1492,110 @@ nav.scrolled .locale-switcher__option.is-current {
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-error {
margin: 0.375rem 0 0;

View File

@@ -24,6 +24,7 @@ final class LocaleSwitcherTest extends TestCase
{
$html = (new LocaleSwitcher('en', '/'))->render();
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
// locale renders as <span lang=".."> (no hreflang). Together all
@@ -34,7 +35,10 @@ final class LocaleSwitcherTest extends TestCase
"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]