WinterCMS research

This commit is contained in:
Jakub Zych
2026-02-18 01:31:41 +01:00
parent bec00a8bd5
commit 29766aee93
40 changed files with 8529 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
---
phase: 15-locale-detection-routing
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- plugins/golem15/quotify/Plugin.php
- plugins/golem15/translate/routes.php
autonomous: true
must_haves:
truths:
- "Manual locale selection via LocalePicker sets 1-year cookie"
- "URL prefix visit (/pl/, /de/) sets manual selection cookie"
- "Logged-in user's locale switch updates their DB preference"
- "Browser detection skipped when manual selection cookie exists"
artifacts:
- path: "plugins/golem15/quotify/Plugin.php"
provides: "LocalePicker extension with cookie and DB sync"
contains: "LocalePicker::extend"
- path: "plugins/golem15/translate/routes.php"
provides: "Cookie setting on URL prefix detection"
contains: "locale_manually_set"
key_links:
- from: "LocalePicker::onSwitchLocale"
to: "Cookie::queue"
via: "component extension in Plugin.php boot()"
pattern: "Cookie::queue.*locale_manually_set"
- from: "LocalePicker::onSwitchLocale"
to: "User::preferred_locale"
via: "Auth::getUser()->save()"
pattern: "preferred_locale.*=.*locale"
---
<objective>
Implement core locale preference infrastructure: cookie setting for manual selections and database sync for logged-in users.
Purpose: Fix the identified gap where `locale_manually_set` cookie is read by middleware but never written, and ensure logged-in users' preferences persist to database.
Output: LocalePicker extension in Quotify Plugin.php, updated routes.php with cookie setting on URL prefix
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/15-locale-detection-routing/15-CONTEXT.md
@.planning/phases/15-locale-detection-routing/15-RESEARCH.md
@plugins/golem15/quotify/Plugin.php
@plugins/golem15/translate/routes.php
@plugins/golem15/translate/components/LocalePicker.php
@plugins/golem15/translate/classes/LocaleMiddleware.php
</context>
<tasks>
<task type="auto">
<name>Task 1: Extend LocalePicker to set cookie and sync user preference</name>
<files>plugins/golem15/quotify/Plugin.php</files>
<action>
In the boot() method of Quotify Plugin.php, add an extension to LocalePicker component:
```php
// Extend LocalePicker to set manual selection cookie and sync user preference
\Golem15\Translate\Components\LocalePicker::extend(function($component) {
$component->bindEvent('component.beforeRunAjaxHandler', function($handler) use ($component) {
if ($handler === 'onSwitchLocale') {
$locale = post('locale');
// Validate locale before proceeding
if (!$locale || !\Golem15\Translate\Models\Locale::isValid($locale)) {
return;
}
// Set manual selection cookie (1 year = 525600 minutes)
\Cookie::queue(
\Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'),
'1',
\Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600)
);
// Update user preference in DB if logged in
if (\Auth::check()) {
$user = \Auth::getUser();
$user->preferred_locale = $locale;
$user->save();
}
}
});
});
```
Add this after the existing extensions in boot() (after User model extensions). Import Cookie facade at top if not present.
Do NOT modify the core LocalePicker.php in the Translate plugin - use WinterCMS extension pattern.
</action>
<verify>Check Plugin.php contains LocalePicker::extend with Cookie::queue and Auth::getUser()->preferred_locale logic</verify>
<done>LocalePicker component extended to set cookie and sync DB preference on locale switch</done>
</task>
<task type="auto">
<name>Task 2: Set cookie on URL prefix detection in routes.php</name>
<files>plugins/golem15/translate/routes.php</files>
<action>
In routes.php, after the locale is detected from URL prefix (after line 23 where `$translator->loadLocaleFromRequest()` succeeds), add cookie setting:
```php
// After: if (!$translator->loadLocaleFromRequest() || (!$locale = $translator->getLocale()))
// Inside the check where locale WAS loaded from request:
// Set manual selection cookie when URL has locale prefix
// This prevents browser detection from overriding explicit URL visits
\Cookie::queue(
\Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'),
'1',
\Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600)
);
```
The existing code structure is:
```php
if (
!$translator->isConfigured() ||
!$translator->loadLocaleFromRequest() || // This returns true when URL has prefix
(!$locale = $translator->getLocale())
) {
return;
}
// If we get here, locale WAS loaded from URL prefix
// ADD COOKIE SETTING HERE before route registration
```
Cookie import should use full namespace `\Cookie::queue()` to avoid import issues.
</action>
<verify>Check routes.php contains Cookie::queue call after loadLocaleFromRequest succeeds</verify>
<done>URL prefix visits (/pl/, /de/) set manual selection cookie to prevent browser detection override</done>
</task>
<task type="auto">
<name>Task 3: Verify middleware reads the cookie correctly</name>
<files>plugins/golem15/translate/classes/LocaleMiddleware.php</files>
<action>
Verify (read-only) that LocaleMiddleware::hasManualLocaleSelection() correctly reads the cookie:
1. Check it reads from config: `golem15.translate::browserDetection.manualSelectionCookie`
2. Check it returns true when cookie is present
3. Check it's called in the locale detection cascade (Priority 4)
This is a verification task - the middleware already has this logic from the research. Just confirm it works with our cookie name.
If any issues found, log them. The middleware should NOT be modified unless absolutely necessary (it's a shared plugin).
</action>
<verify>Read LocaleMiddleware.php and confirm hasManualLocaleSelection() checks for the correct cookie name</verify>
<done>Confirmed middleware reads locale_manually_set cookie and skips browser detection when present</done>
</task>
</tasks>
<verification>
1. Code review: Plugin.php has LocalePicker::extend with cookie + DB sync
2. Code review: routes.php sets cookie after URL prefix detection
3. Code review: LocaleMiddleware reads same cookie name
4. Manual test: Switch language via picker, check cookie is set (DevTools > Application > Cookies)
5. Manual test: Visit /pl/jobs, check cookie is set
6. Manual test: Clear cookies, visit site, browser detection should work
7. Manual test: Set language via picker, browser detection should be skipped on next visit
</verification>
<success_criteria>
- LocalePicker::onSwitchLocale sets locale_manually_set cookie (1 year expiry)
- LocalePicker::onSwitchLocale updates User::preferred_locale for logged-in users
- URL prefix visits (/pl/, /de/) set locale_manually_set cookie
- LocaleMiddleware skips browser detection when cookie present
- No modifications to core Translate plugin classes (only routes.php touched)
</success_criteria>
<output>
After completion, create `.planning/phases/15-locale-detection-routing/15-01-SUMMARY.md`
</output>

View File

@@ -0,0 +1,103 @@
---
phase: 15-locale-detection-routing
plan: 01
subsystem: i18n
tags: [locale, cookie, middleware, wintercms-extension, browser-detection]
# Dependency graph
requires:
- phase: 11-translation-infrastructure
provides: Translate plugin with LocaleMiddleware and browser detection config
provides:
- LocalePicker extension setting manual selection cookie
- Database sync of user locale preference on switch
- URL prefix cookie setting in routes.php
affects: [16-content-localization, 17-i18n-testing]
# Tech tracking
tech-stack:
added: []
patterns: [WinterCMS component extension in Plugin boot(), Cookie queue pattern]
key-files:
created: []
modified:
- plugins/golem15/quotify/Plugin.php
- plugins/golem15/translate/routes.php
key-decisions:
- "Use WinterCMS extension pattern (no modification to core Translate plugin classes)"
- "Cookie name from config: golem15.translate::browserDetection.manualSelectionCookie"
- "1-year cookie expiry (525600 minutes) from config"
patterns-established:
- "LocalePicker extension: Use component.beforeRunAjaxHandler event for pre-handler logic"
- "Cookie setting: Use \\Cookie::queue() with config-based name and expiry"
# Metrics
duration: 2min
completed: 2026-02-02
---
# Phase 15 Plan 01: Locale Preference Infrastructure Summary
**LocalePicker extension with manual selection cookie (1-year) and user DB sync, plus URL prefix cookie setting in routes.php**
## Performance
- **Duration:** 2 min
- **Started:** 2026-02-02T01:01:04Z
- **Completed:** 2026-02-02T01:02:55Z
- **Tasks:** 3
- **Files modified:** 2
## Accomplishments
- LocalePicker component extended to set `locale_manually_set` cookie on manual language switch
- Logged-in users' locale preference saved to database (`preferred_locale` field) on switch
- URL prefix visits (/pl/, /de/) now set manual selection cookie preventing browser detection override
- Verified LocaleMiddleware reads the same cookie name correctly
## Task Commits
Each task was committed atomically:
1. **Task 1: Extend LocalePicker to set cookie and sync user preference** - `4a04780` (feat) [quotify submodule]
2. **Task 2: Set cookie on URL prefix detection in routes.php** - `c389de1` (feat) [translate submodule]
3. **Task 3: Verify middleware reads the cookie correctly** - verification only, no commit required
**Submodule update:** `382b7a6` (chore: update submodule references)
## Files Created/Modified
- `plugins/golem15/quotify/Plugin.php` - Added extendLocalePicker() method with Cookie::queue and Auth user sync
- `plugins/golem15/translate/routes.php` - Added cookie setting after loadLocaleFromRequest() succeeds
## Decisions Made
- **WinterCMS extension pattern:** Extended LocalePicker component in Quotify Plugin.php rather than modifying core Translate plugin classes (preserves plugin isolation)
- **Config-driven cookie settings:** Used `golem15.translate::browserDetection.manualSelectionCookie` and `manualSelectionExpiry` config values for consistency across all cookie operations
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None - all tasks completed successfully.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Locale preference infrastructure complete
- Manual selections now persist via cookie (1-year expiry)
- Logged-in users' preferences sync to database
- Browser detection correctly skipped when manual selection cookie present
- Ready for Phase 15 Plan 02 (if any) or Phase 16 (Content Localization)
---
*Phase: 15-locale-detection-routing*
*Completed: 2026-02-02*

View File

@@ -0,0 +1,388 @@
---
phase: 15-locale-detection-routing
plan: 02
type: execute
wave: 2
depends_on: ["15-01"]
files_modified:
- themes/quotify/partials/header.htm
- themes/quotify/partials/language-switcher.htm
- themes/quotify/layouts/default.htm
- themes/quotify/layouts/dashboard.htm
- themes/quotify/layouts/empty.htm
- themes/quotify/assets/css/components/language-switcher.css
- themes/quotify/assets/css/app.css
autonomous: true
must_haves:
truths:
- "Language switcher dropdown visible in header"
- "Clicking language option switches locale and reloads page"
- "Current language name shown in native form (Polski, Deutsch, English)"
- "All pages have hreflang tags for SEO"
artifacts:
- path: "themes/quotify/partials/language-switcher.htm"
provides: "Dropdown language switcher UI"
contains: "data-request=\"localePicker::onSwitchLocale\""
- path: "themes/quotify/partials/header.htm"
provides: "Header with language switcher"
contains: "language-switcher"
- path: "themes/quotify/layouts/default.htm"
provides: "Layout with hreflang and LocalePicker"
contains: "alternateHrefLangElements"
key_links:
- from: "themes/quotify/partials/language-switcher.htm"
to: "localePicker::onSwitchLocale"
via: "data-request attribute"
pattern: "data-request.*localePicker::onSwitchLocale"
- from: "themes/quotify/layouts/default.htm"
to: "AlternateHrefLangElements component"
via: "component registration"
pattern: "\\[localePicker\\]|\\[alternateHrefLangElements\\]"
---
<objective>
Create language switcher UI in header and integrate hreflang tags for SEO across all layouts.
Purpose: Enable users to switch languages via visible dropdown in header, with proper SEO markup for search engines.
Output: Language switcher partial, updated header, hreflang integration in all layouts
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/15-locale-detection-routing/15-CONTEXT.md
@.planning/phases/15-locale-detection-routing/15-RESEARCH.md
@themes/quotify/partials/header.htm
@themes/quotify/layouts/default.htm
@themes/quotify/layouts/dashboard.htm
@themes/quotify/assets/css/app.css
@plugins/golem15/translate/components/LocalePicker.php
@plugins/golem15/translate/components/AlternateHrefLangElements.php
</context>
<tasks>
<task type="auto">
<name>Task 1: Create language switcher partial and CSS</name>
<files>
themes/quotify/partials/language-switcher.htm
themes/quotify/assets/css/components/language-switcher.css
themes/quotify/assets/css/app.css
</files>
<action>
Create `themes/quotify/partials/language-switcher.htm`:
```twig
{% set locales = localePicker.locales %}
{% set activeLocale = localePicker.activeLocale %}
{% set activeLocaleName = localePicker.activeLocaleName %}
<div class="language-switcher" data-language-switcher>
<button type="button" class="language-switcher-toggle" aria-expanded="false" aria-haspopup="listbox" aria-label="{{ 'Select language'|_ }}">
<span class="language-switcher-label">{{ activeLocaleName }}</span>
<svg class="language-switcher-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
<ul class="language-switcher-list" role="listbox" aria-label="{{ 'Available languages'|_ }}">
{% for code, name in locales %}
<li role="option" {% if code == activeLocale %}aria-selected="true"{% endif %}>
<a href="#"
class="language-switcher-option {% if code == activeLocale %}active{% endif %}"
data-request="localePicker::onSwitchLocale"
data-request-data="locale: '{{ code }}'">
{{ name }}
</a>
</li>
{% endfor %}
</ul>
</div>
```
Create `themes/quotify/assets/css/components/language-switcher.css`:
```css
/* Language Switcher */
.language-switcher {
position: relative;
}
.language-switcher-toggle {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-2) var(--space-3);
background: transparent;
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
font-size: var(--text-sm);
font-weight: 500;
color: var(--color-gray-700);
cursor: pointer;
transition: all var(--transition-fast);
}
.language-switcher-toggle:hover {
border-color: var(--color-gray-300);
background: var(--color-gray-50);
}
.language-switcher-toggle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.language-switcher-icon {
width: 16px;
height: 16px;
transition: transform var(--transition-fast);
}
.language-switcher.open .language-switcher-icon {
transform: rotate(180deg);
}
.language-switcher-list {
position: absolute;
top: 100%;
right: 0;
z-index: 50;
min-width: 140px;
margin-top: var(--space-1);
padding: var(--space-1);
background: var(--color-white);
border: 1px solid var(--color-gray-200);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
list-style: none;
opacity: 0;
visibility: hidden;
transform: translateY(-8px);
transition: all var(--transition-fast);
}
.language-switcher.open .language-switcher-list {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.language-switcher-option {
display: block;
padding: var(--space-2) var(--space-3);
font-size: var(--text-sm);
color: var(--color-gray-700);
text-decoration: none;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.language-switcher-option:hover {
background: var(--color-gray-50);
color: var(--color-gray-900);
}
.language-switcher-option.active {
background: rgba(0, 102, 102, 0.08);
color: var(--color-primary);
font-weight: 500;
}
.language-switcher-option:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
/* Mobile: Slightly larger touch targets */
@media (max-width: 768px) {
.language-switcher-toggle {
padding: var(--space-2);
}
.language-switcher-label {
display: none;
}
.language-switcher-toggle::before {
content: '';
width: 20px;
height: 20px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='currentColor'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
.language-switcher-option {
padding: var(--space-3) var(--space-4);
}
}
```
Add import to `themes/quotify/assets/css/app.css` after other component imports:
```css
@import 'components/language-switcher.css';
```
Add JavaScript for dropdown toggle to the partial (inline script at bottom):
```html
<script>
(function() {
var switcher = document.querySelector('[data-language-switcher]');
if (!switcher) return;
var toggle = switcher.querySelector('.language-switcher-toggle');
var list = switcher.querySelector('.language-switcher-list');
toggle.addEventListener('click', function(e) {
e.preventDefault();
var isOpen = switcher.classList.contains('open');
switcher.classList.toggle('open');
toggle.setAttribute('aria-expanded', !isOpen);
});
// Close on click outside
document.addEventListener('click', function(e) {
if (!switcher.contains(e.target)) {
switcher.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
}
});
// Close on Escape
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape' && switcher.classList.contains('open')) {
switcher.classList.remove('open');
toggle.setAttribute('aria-expanded', 'false');
toggle.focus();
}
});
})();
</script>
```
</action>
<verify>Files exist and contain expected content: dropdown HTML, CSS styles, JS toggle logic</verify>
<done>Language switcher partial created with accessible dropdown, CSS styling, and toggle JavaScript</done>
</task>
<task type="auto">
<name>Task 2: Integrate language switcher into header</name>
<files>themes/quotify/partials/header.htm</files>
<action>
Update `themes/quotify/partials/header.htm` to include the language switcher in the desktop navigation area, near the right side of the header.
Add the language switcher partial between the nav and the mobile menu toggle:
```twig
<!-- Desktop Navigation -->
<nav class="main-nav" aria-label="Main navigation">
{% partial 'nav' %}
</nav>
<!-- Language Switcher -->
<div class="header-actions">
{% partial 'language-switcher' %}
</div>
<!-- Mobile Menu Toggle -->
```
Add CSS for header-actions positioning (add to header.htm inline styles or ensure it exists in header CSS):
The header-actions div should be positioned to the right, before the mobile menu toggle. If using flexbox on header-inner, it will naturally flow to the right.
Style addition (can be inline or in existing header CSS):
```css
.header-actions {
display: flex;
align-items: center;
gap: var(--space-3);
margin-left: auto;
}
@media (max-width: 768px) {
.header-actions {
order: 2; /* After logo, before menu toggle */
}
}
```
</action>
<verify>Header partial includes language-switcher partial with header-actions wrapper</verify>
<done>Language switcher integrated into site header, visible on all pages</done>
</task>
<task type="auto">
<name>Task 3: Register components and add hreflang to all layouts</name>
<files>
themes/quotify/layouts/default.htm
themes/quotify/layouts/dashboard.htm
themes/quotify/layouts/empty.htm
</files>
<action>
Update all three layouts to:
1. Register localePicker and alternateHrefLangElements components
2. Add hreflang output in head section
For each layout, add component registration in the configuration section:
```ini
[localePicker]
[alternateHrefLangElements]
```
In the `<head>` section, after the meta tags and before stylesheets, add:
```twig
<!-- Alternate language URLs for SEO -->
{% component 'alternateHrefLangElements' %}
```
This generates:
```html
<link rel="alternate" hreflang="en" href="https://quotify.pro/current-page">
<link rel="alternate" hreflang="pl" href="https://quotify.pro/pl/current-page">
<link rel="alternate" hreflang="de" href="https://quotify.pro/de/current-page">
```
For default.htm - add both components
For dashboard.htm - add both components
For empty.htm - check if it exists, add both components if present
The localePicker component is needed so the language-switcher partial can access its properties (locales, activeLocale, etc.).
</action>
<verify>All layouts register localePicker and alternateHrefLangElements components, and output hreflang in head</verify>
<done>All layouts have LocalePicker for switcher functionality and hreflang tags for SEO</done>
</task>
</tasks>
<verification>
1. Visual: Language switcher dropdown visible in header on all pages
2. Functional: Click switcher, dropdown opens with language options
3. Functional: Click a language option, page reloads in that language
4. Accessibility: Switcher focusable, Escape closes dropdown
5. SEO: View page source, confirm hreflang tags present for en, pl, de
6. Mobile: On mobile viewport, switcher shows icon only (label hidden)
7. Styling: Matches design palette (teal primary, proper spacing)
</verification>
<success_criteria>
- Language switcher partial exists with dropdown UI
- Header displays language switcher near right side
- Dropdown shows all enabled locales (English, Polski, Deutsch)
- Clicking language triggers localePicker::onSwitchLocale
- All layouts register localePicker and alternateHrefLangElements components
- hreflang tags present in page source for all enabled locales
- Responsive design: icon-only on mobile
</success_criteria>
<output>
After completion, create `.planning/phases/15-locale-detection-routing/15-02-SUMMARY.md`
</output>

View File

@@ -0,0 +1,110 @@
---
phase: 15-locale-detection-routing
plan: 02
subsystem: ui
tags: [i18n, locale, hreflang, SEO, accessibility]
# Dependency graph
requires:
- phase: 15-01
provides: LocalePicker component with manual selection cookie handling
provides:
- Language switcher dropdown UI partial
- Header integration with language switcher
- hreflang tags for SEO across all layouts
affects: [16-browser-detection, 17-locale-testing]
# Tech tracking
tech-stack:
added: []
patterns:
- Accessible dropdown with ARIA attributes
- CSS-only dropdown animation (opacity/visibility/transform)
- Mobile-responsive icon-only switcher
key-files:
created:
- themes/quotify/partials/language-switcher.htm
- themes/quotify/assets/css/components/language-switcher.css
modified:
- themes/quotify/partials/header.htm
- themes/quotify/layouts/default.htm
- themes/quotify/layouts/dashboard.htm
- themes/quotify/layouts/empty.htm
- themes/quotify/assets/css/app.css
- themes/quotify/assets/css/layout.css
key-decisions:
- "Native language names in switcher (Polski, Deutsch, English)"
- "Icon-only switcher on mobile (globe icon via SVG data URI)"
- "hreflang tags in head before favicon for SEO priority"
patterns-established:
- "Language switcher uses data-request to localePicker::onSwitchLocale"
- "Components directory for modular CSS (components/language-switcher.css)"
- "header-actions wrapper for header utilities"
# Metrics
duration: 2min
completed: 2026-02-02
---
# Phase 15 Plan 02: Language Switcher UI Summary
**Accessible language switcher dropdown in header with hreflang SEO tags across all layouts**
## Performance
- **Duration:** 2 min
- **Started:** 2026-02-02T01:05:47Z
- **Completed:** 2026-02-02T01:07:48Z
- **Tasks:** 3
- **Files modified:** 8
## Accomplishments
- Created language switcher partial with accessible dropdown (ARIA attributes, keyboard navigation)
- Integrated switcher into site header with responsive styling
- Added localePicker and alternateHrefLangElements components to all layouts
- hreflang tags now output in head section for SEO
## Task Commits
Each task was committed atomically:
1. **Task 1: Create language switcher partial and CSS** - `2e045dd` (feat)
2. **Task 2: Integrate language switcher into header** - `ec454b5` (feat)
3. **Task 3: Register components and add hreflang to layouts** - `0a07da8` (feat)
## Files Created/Modified
- `themes/quotify/partials/language-switcher.htm` - Dropdown UI with locale options
- `themes/quotify/assets/css/components/language-switcher.css` - Responsive dropdown styles
- `themes/quotify/assets/css/app.css` - Import for language-switcher CSS
- `themes/quotify/assets/css/layout.css` - header-actions positioning styles
- `themes/quotify/partials/header.htm` - Language switcher integration
- `themes/quotify/layouts/default.htm` - localePicker + hreflang
- `themes/quotify/layouts/dashboard.htm` - localePicker + hreflang
- `themes/quotify/layouts/empty.htm` - localePicker + hreflang
## Decisions Made
- **Native language names:** Display "Polski", "Deutsch", "English" (not English translations)
- **Mobile icon:** Globe SVG as data URI in CSS for icon-only mobile display
- **hreflang placement:** Before favicon in head section for SEO priority
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Language switcher fully functional with all three locales
- Clicking any language triggers localePicker::onSwitchLocale which sets cookie and redirects
- hreflang tags enable search engines to index all language versions
- Ready for Phase 16 (Browser Detection) or Phase 17 (Locale Testing)
---
*Phase: 15-locale-detection-routing*
*Completed: 2026-02-02*

View File

@@ -0,0 +1,433 @@
---
phase: 15-locale-detection-routing
plan: 03
type: execute
wave: 2
depends_on: ["15-01"]
files_modified:
- themes/quotify/partials/account/update.htm
- plugins/golem15/translate/components/LocaleSuggestionBanner.php
- plugins/golem15/translate/components/localesuggestionbanner/default.htm
- themes/quotify/layouts/default.htm
- themes/quotify/assets/css/components/locale-banner.css
- themes/quotify/assets/css/app.css
autonomous: true
must_haves:
truths:
- "Account settings has language preference section"
- "Logged-in users can select preferred language in profile"
- "Language suggestion banner appears when browser language differs"
- "Dismissing banner sets cookie to prevent showing again"
artifacts:
- path: "themes/quotify/partials/account/update.htm"
provides: "Language preference section in account settings"
contains: "preferred_locale"
- path: "plugins/golem15/translate/components/LocaleSuggestionBanner.php"
provides: "Component for language mismatch detection"
contains: "shouldShowBanner"
- path: "plugins/golem15/translate/components/localesuggestionbanner/default.htm"
provides: "Banner template"
contains: "locale-suggestion-banner"
key_links:
- from: "themes/quotify/partials/account/update.htm"
to: "Account::onUpdate"
via: "form submission with preferred_locale field"
pattern: "name=\"preferred_locale\""
- from: "LocaleSuggestionBanner"
to: "LocaleMiddleware detection"
via: "Accept-Language comparison"
pattern: "getSuggestedLocale|detectBrowserLocale"
---
<objective>
Add language preference to account settings and create locale suggestion banner for browser language mismatches.
Purpose: Allow logged-in users to explicitly set their preferred language, and help new visitors discover available translations.
Output: Language preference section in account settings, LocaleSuggestionBanner component with dismissible banner UI
</objective>
<execution_context>
@/home/jin/.claude/get-shit-done/workflows/execute-plan.md
@/home/jin/.claude/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/phases/15-locale-detection-routing/15-CONTEXT.md
@.planning/phases/15-locale-detection-routing/15-RESEARCH.md
@themes/quotify/partials/account/update.htm
@plugins/golem15/user/components/Account.php
@plugins/golem15/translate/components/LocalePicker.php
</context>
<tasks>
<task type="auto">
<name>Task 1: Add language preference section to account settings</name>
<files>themes/quotify/partials/account/update.htm</files>
<action>
Add a new "Language Preference" section to the Account Info tab in account/update.htm.
Add the section between "Profile Information" and "Change Password" sections:
```twig
<!-- Language Preference Section -->
<div class="account-section">
<div class="account-section-header">
<h3 class="account-section-title">{{ 'Language Preference'|_ }}</h3>
<p class="account-section-description">{{ 'Choose your preferred language for the site.'|_ }}</p>
</div>
<div class="account-section-content">
<div class="form-group">
<label for="preferredLocale" class="form-label">{{ 'Preferred Language'|_ }}</label>
<select name="preferred_locale" id="preferredLocale" class="form-control">
{% set locales = {'en': 'English', 'pl': 'Polski', 'de': 'Deutsch'} %}
{% for code, name in locales %}
<option value="{{ code }}" {% if user.preferred_locale == code %}selected{% endif %}>
{{ name }}
</option>
{% endfor %}
</select>
<p class="form-hint">{{ 'This language will be used when you log in.'|_ }}</p>
</div>
</div>
</div>
```
The Account component's onUpdate handler already processes all posted fields including preferred_locale (it's a fillable field on User model).
Place this section at the start of the "Account Info" tab content (id="account-info"), before the Profile Information section.
</action>
<verify>Account settings page shows Language Preference dropdown with en/pl/de options</verify>
<done>Logged-in users can select and save their preferred language in account settings</done>
</task>
<task type="auto">
<name>Task 2: Create LocaleSuggestionBanner component</name>
<files>
plugins/golem15/translate/components/LocaleSuggestionBanner.php
plugins/golem15/translate/components/localesuggestionbanner/default.htm
</files>
<action>
Create the LocaleSuggestionBanner component in the Translate plugin.
Create `plugins/golem15/translate/components/LocaleSuggestionBanner.php`:
```php
<?php
namespace Golem15\Translate\Components;
use Cms\Classes\ComponentBase;
use Golem15\Translate\Classes\Translator;
use Golem15\Translate\Models\Locale;
use Request;
use Config;
use Cookie;
class LocaleSuggestionBanner extends ComponentBase
{
public function componentDetails(): array
{
return [
'name' => 'Locale Suggestion Banner',
'description' => 'Shows banner when browser language differs from current page'
];
}
public function onRun()
{
$this->page['showBanner'] = $this->shouldShowBanner();
$this->page['suggestedLocale'] = $this->getSuggestedLocale();
$this->page['suggestedLocaleName'] = $this->getSuggestedLocaleName();
}
protected function shouldShowBanner(): bool
{
// Don't show if manually set (user explicitly chose a language)
$cookieName = Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set');
if (Request::cookie($cookieName)) {
return false;
}
// Don't show if dismissed this session
if (session('locale_banner_dismissed')) {
return false;
}
// Don't show if 1-week dismissal cookie exists
if (Request::cookie('locale_banner_dismissed')) {
return false;
}
// Check if browser language differs from current
return $this->getSuggestedLocale() !== null;
}
protected function getSuggestedLocale(): ?string
{
$browserLocale = $this->detectBrowserLocale();
$currentLocale = Translator::instance()->getLocale();
if ($browserLocale && $browserLocale !== $currentLocale) {
return $browserLocale;
}
return null;
}
protected function detectBrowserLocale(): ?string
{
$acceptLanguage = Request::header('Accept-Language');
if (!$acceptLanguage) return null;
$enabledLocales = array_keys(Locale::listEnabled());
$candidates = explode(',', $acceptLanguage);
foreach ($candidates as $candidate) {
// Extract primary language code (e.g., "pl-PL;q=0.9" -> "pl")
$code = strtolower(substr(trim(explode(';', $candidate)[0]), 0, 2));
if (in_array($code, $enabledLocales)) {
return $code;
}
}
return null;
}
protected function getSuggestedLocaleName(): ?string
{
$locale = $this->getSuggestedLocale();
if (!$locale) return null;
$locales = Locale::listEnabled();
return $locales[$locale] ?? null;
}
public function onDismissBanner()
{
session(['locale_banner_dismissed' => true]);
// Set 1-week dismissal cookie (7 days = 10080 minutes)
Cookie::queue('locale_banner_dismissed', '1', 10080);
return ['#locale-suggestion-banner' => ''];
}
public function onSwitchToSuggested()
{
$locale = post('locale');
if (!Locale::isValid($locale)) {
return;
}
Translator::instance()->setLocale($locale, true);
// Set manual selection cookie (1 year)
Cookie::queue(
Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'),
'1',
Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600)
);
return \Redirect::refresh();
}
}
```
Create directory and template `plugins/golem15/translate/components/localesuggestionbanner/default.htm`:
```twig
{% if showBanner and suggestedLocale %}
<div class="locale-suggestion-banner" id="locale-suggestion-banner" role="alert">
<div class="container">
<div class="locale-suggestion-content">
<p class="locale-suggestion-text">
{% if suggestedLocale == 'pl' %}
Ta strona jest dostepna po polsku
{% elseif suggestedLocale == 'de' %}
Diese Seite ist auf Deutsch verfügbar
{% else %}
{{ 'This page is available in'|_ }} {{ suggestedLocaleName }}
{% endif %}
</p>
<div class="locale-suggestion-actions">
<button type="button"
class="btn btn-sm btn-primary"
data-request="{{ __SELF__ }}::onSwitchToSuggested"
data-request-data="locale: '{{ suggestedLocale }}'">
{% if suggestedLocale == 'pl' %}Przełącz na polski{% elseif suggestedLocale == 'de' %}Zu Deutsch wechseln{% else %}{{ 'Switch'|_ }}{% endif %}
</button>
<button type="button"
class="locale-suggestion-dismiss"
data-request="{{ __SELF__ }}::onDismissBanner"
data-request-update="{ '#locale-suggestion-banner': '' }"
aria-label="{{ 'Dismiss'|_ }}">
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</button>
</div>
</div>
</div>
</div>
{% endif %}
```
Register the component in `plugins/golem15/translate/Plugin.php` registerComponents() method (if not already auto-registered).
</action>
<verify>Component file exists, template file exists, component can be registered in layout</verify>
<done>LocaleSuggestionBanner component created with browser language detection and dismissible banner</done>
</task>
<task type="auto">
<name>Task 3: Add banner CSS and integrate into layout</name>
<files>
themes/quotify/assets/css/components/locale-banner.css
themes/quotify/assets/css/app.css
themes/quotify/layouts/default.htm
</files>
<action>
Create `themes/quotify/assets/css/components/locale-banner.css`:
```css
/* Locale Suggestion Banner */
.locale-suggestion-banner {
position: sticky;
top: 0;
z-index: 200;
background: linear-gradient(135deg, var(--color-primary), #004d4d);
color: var(--color-white);
padding: var(--space-3) 0;
}
.locale-suggestion-content {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-4);
flex-wrap: wrap;
}
.locale-suggestion-text {
margin: 0;
font-size: var(--text-sm);
font-weight: 500;
}
.locale-suggestion-actions {
display: flex;
align-items: center;
gap: var(--space-3);
}
.locale-suggestion-banner .btn-primary {
background: var(--color-white);
color: var(--color-primary);
border-color: var(--color-white);
padding: var(--space-1) var(--space-3);
font-size: var(--text-sm);
}
.locale-suggestion-banner .btn-primary:hover {
background: rgba(255, 255, 255, 0.9);
}
.locale-suggestion-dismiss {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.8);
cursor: pointer;
border-radius: var(--radius-sm);
transition: all var(--transition-fast);
}
.locale-suggestion-dismiss:hover {
color: var(--color-white);
background: rgba(255, 255, 255, 0.1);
}
.locale-suggestion-dismiss svg {
width: 18px;
height: 18px;
}
.locale-suggestion-dismiss:focus-visible {
outline: 2px solid var(--color-white);
outline-offset: 2px;
}
@media (max-width: 480px) {
.locale-suggestion-content {
flex-direction: column;
text-align: center;
gap: var(--space-3);
}
.locale-suggestion-actions {
width: 100%;
justify-content: center;
}
}
```
Add import to `themes/quotify/assets/css/app.css`:
```css
@import 'components/locale-banner.css';
```
Update `themes/quotify/layouts/default.htm` to:
1. Register the localeSuggestionBanner component
2. Include the banner partial after flash messages, before page-wrapper
Add to component registration:
```ini
[localeSuggestionBanner]
```
Add to body, after flash partial and before page-wrapper:
```twig
{% component 'localeSuggestionBanner' %}
```
</action>
<verify>CSS file exists, app.css imports it, default layout registers and displays the banner component</verify>
<done>Locale suggestion banner styled and integrated into default layout</done>
</task>
</tasks>
<verification>
1. Account settings: Language Preference section visible with dropdown
2. Account settings: Selecting language and saving updates user.preferred_locale
3. Banner: Clear all cookies, set browser language to German, visit English page
4. Banner: German suggestion banner appears at top of page
5. Banner: Click "Zu Deutsch wechseln" switches to German and sets cookie
6. Banner: Click X to dismiss, banner disappears
7. Banner: Refresh page, banner does not reappear (cookie prevents)
8. Banner: After 1 week cookie expires, banner would show again (can't test easily)
</verification>
<success_criteria>
- Account settings has Language Preference section with en/pl/de dropdown
- Saving preference updates User::preferred_locale in database
- LocaleSuggestionBanner component detects browser language mismatch
- Banner appears when browser language differs from page language
- Banner shows localized text ("Diese Seite ist auf Deutsch verfügbar")
- Switch button changes language and sets manual selection cookie
- Dismiss button hides banner and sets 1-week cookie
- Banner respects locale_manually_set cookie (doesn't show if user already chose)
</success_criteria>
<output>
After completion, create `.planning/phases/15-locale-detection-routing/15-03-SUMMARY.md`
</output>

View File

@@ -0,0 +1,112 @@
---
phase: 15-locale-detection-routing
plan: 03
subsystem: ui
tags: [locale, i18n, cookies, accept-language, account-settings]
# Dependency graph
requires:
- phase: 15-01
provides: preferred_locale field on User model, LocaleMiddleware detection
provides:
- Language preference section in account settings
- LocaleSuggestionBanner component for browser language detection
- Dismissible banner with cookie persistence
affects: [user-experience, theme]
# Tech tracking
tech-stack:
added: []
patterns: [browser-locale-detection, dismissal-cookie-pattern]
key-files:
created:
- plugins/golem15/translate/components/LocaleSuggestionBanner.php
- plugins/golem15/translate/components/localesuggestionbanner/default.htm
- themes/quotify/assets/css/components/locale-banner.css
modified:
- themes/quotify/partials/account/update.htm
- themes/quotify/layouts/default.htm
- themes/quotify/assets/css/app.css
- plugins/golem15/translate/Plugin.php
key-decisions:
- "Banner shows localized text in target language (e.g., 'Diese Seite ist auf Deutsch verfugbar')"
- "1-week dismissal cookie (10080 minutes) prevents banner showing repeatedly"
- "Banner respects locale_manually_set cookie from LocalePicker"
patterns-established:
- "Accept-Language parsing: extract 2-char code, match against enabled locales"
- "Dismissal pattern: session flag + persistent cookie for multi-session"
# Metrics
duration: 2min
completed: 2026-02-02
---
# Phase 15 Plan 03: Language Preferences & Suggestion Banner Summary
**Account settings language preference dropdown and LocaleSuggestionBanner component with Accept-Language detection and dismissible UI**
## Performance
- **Duration:** 2 min
- **Started:** 2026-02-02T01:06:50Z
- **Completed:** 2026-02-02T01:09:02Z
- **Tasks:** 3
- **Files modified:** 7
## Accomplishments
- Added Language Preference section to account settings with en/pl/de dropdown
- Created LocaleSuggestionBanner component with browser language detection
- Implemented dismissal cookie and manual selection cookie integration
- Added responsive sticky banner CSS with gradient styling
## Task Commits
Each task was committed atomically:
1. **Task 1: Add language preference section to account settings** - `652b035` (feat) [theme]
2. **Task 2: Create LocaleSuggestionBanner component** - `8f8885f` (feat) [translate plugin]
3. **Task 3: Add banner CSS and integrate into layout** - `006987e` (feat) [theme]
**Submodule update:** `82796d1` (feat: submodule pointer update)
## Files Created/Modified
**Created:**
- `plugins/golem15/translate/components/LocaleSuggestionBanner.php` - Component with browser detection
- `plugins/golem15/translate/components/localesuggestionbanner/default.htm` - Banner partial
- `themes/quotify/assets/css/components/locale-banner.css` - Banner styles
**Modified:**
- `themes/quotify/partials/account/update.htm` - Added Language Preference section
- `themes/quotify/layouts/default.htm` - Registered and rendered banner component
- `themes/quotify/assets/css/app.css` - Imported locale-banner.css
- `plugins/golem15/translate/Plugin.php` - Registered LocaleSuggestionBanner component
## Decisions Made
- Banner shows localized text in the suggested language (Polish/German) rather than current page language
- Uses 1-week dismissal cookie to prevent annoyance while still showing eventually
- Banner appears after flash messages, before page-wrapper for maximum visibility
## Deviations from Plan
None - plan executed exactly as written.
## Issues Encountered
None.
## User Setup Required
None - no external service configuration required.
## Next Phase Readiness
- Language preferences fully functional for logged-in users
- Browser language detection and suggestion banner working
- Ready for Phase 15-04 (Auto-redirect Strategy) if planned
---
*Phase: 15-locale-detection-routing*
*Completed: 2026-02-02*

View File

@@ -0,0 +1,77 @@
# Phase 15: Locale Detection & Routing - Context
**Gathered:** 2026-02-02
**Status:** Ready for planning
<domain>
## Phase Boundary
Enable users to browse the site in their preferred language through URL prefixes (/pl/, /de/) with automatic browser detection and persistent preference. Build on existing Golem15\Translate plugin infrastructure (LocalePicker, LocaleMiddleware, Translator).
</domain>
<decisions>
## Implementation Decisions
### URL structure
- Use WinterCMS standard pattern: visiting `/pl/about` switches locale to Polish, then all subsequent navigation uses normal URLs without prefix (locale persists via session)
- Default locale (English) is prefix-free: `/jobs` not `/en/jobs`
- Config: `prefixDefaultLocale: false` for cleaner default language URLs
- When URL contains explicit locale prefix, set `locale_manually_set` cookie (same as language picker does)
- Automatic hreflang tags via AlternateHrefLangElements component in all layouts
### Detection behavior
- Browser detection via Accept-Language header on FIRST VISIT ONLY
- Once user has session/cookie, never auto-detect again
- Primary language matching: `de-DE``de`, `pl-PL``pl` (extract first 2 chars)
- Priority cascade (already in middleware): URL → User DB preference → Session → Browser detection → Default
- Logged-in user's `preferred_locale` from DB always wins over session/browser (but URL prefix still overrides for that request)
### Language switcher UX
- Location: Header, near user menu (top right)
- Style: Dropdown with full text ("English ▼")
- Language names in native form: "Polski", "Deutsch", "English"
- Switching preserves current page (stay on `/jobs/123`, just change language)
- LocalePicker component with `forceUrl: false` (standard pattern)
### Preference persistence
- Guest users: 1-year cookie for locale preference
- Language picker usage saves to DB for logged-in users (auto-update `preferred_locale`)
- URL prefix visit does NOT update DB preference (only affects session)
- Users set their preferred locale during onboarding (already captured in profile)
- Preferred locale dropdown in user profile settings (Profile section, not Family Settings)
### Language suggestion banner
- Show non-intrusive banner when: browser language differs from current page locale AND user hasn't manually selected
- Example: German browser visits `/pl/jobs` from shared link → show "Diese Seite ist auf Deutsch verfügbar" banner
- Dismissing banner sets 1-week cookie, don't show again until expires
- Only show once per session even without explicit dismissal
### Claude's Discretion
- Exact banner styling and positioning
- Cookie names and implementation details
- Component registration and partial structure
- Mobile responsiveness of language switcher
</decisions>
<specifics>
## Specific Ideas
- "I loved WinterCMS approach" — locale switching via URL prefix, then normal navigation without prefixes
- Must be fully compatible with existing Translate and User plugins — no breaking changes
- Language picker behavior should match URL prefix behavior (both set manual-selection cookie)
</specifics>
<deferred>
## Deferred Ideas
None — discussion stayed within phase scope
</deferred>
---
*Phase: 15-locale-detection-routing*
*Context gathered: 2026-02-02*

View File

@@ -0,0 +1,496 @@
# Phase 15: Locale Detection & Routing - Research
**Researched:** 2026-02-02
**Domain:** WinterCMS locale routing, URL prefix patterns, browser detection, preference persistence
**Confidence:** HIGH
## Summary
This phase extends the existing Golem15\Translate plugin infrastructure to implement URL-based locale routing with automatic browser detection and persistent preference storage. The core infrastructure already exists and is well-implemented - LocaleMiddleware handles the priority cascade (URL > User DB > Session > Browser > Default), Translator manages locale state, and LocalePicker/AlternateHrefLangElements components are available.
The primary work involves: (1) Setting the `locale_manually_set` cookie when switching locales via URL prefix or LocalePicker, (2) Creating a language suggestion banner component for mismatched browser languages, (3) Adding a language preference section to the account settings page, and (4) Integrating AlternateHrefLangElements into all layouts for SEO.
**Primary recommendation:** Leverage existing plugin infrastructure completely. Focus on UI integration (language switcher in header, suggestion banner, account settings), cookie management enhancement, and ensuring logged-in users' preferences sync to DB.
## Standard Stack
The established libraries/tools for this domain:
### Core
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| Golem15\Translate | Current | Full translation infrastructure | Already in codebase, handles locales, middleware, components |
| Golem15\User | Current | User model with `preferred_locale` field | Already has preference field, integrated with middleware |
### Supporting
| Library | Version | Purpose | When to Use |
|---------|---------|---------|-------------|
| LocaleMiddleware | Current | Priority cascade for locale detection | Already registered in CmsController |
| Translator | Current | Singleton for locale state management | Use for all locale operations |
| LocalePicker | Current | Component for language switching | Embed in header |
| AlternateHrefLangElements | Current | Generate hreflang tags | Add to all layouts |
### Already Configured
The following is already configured in `/config/golem15/translate/config.php`:
- `prefixDefaultLocale: false` - English (default) has no URL prefix
- `disableLocalePrefixRoutes: false` - URL prefixes enabled
- `browserDetection.enabled: true` - Browser detection active
- `browserDetection.manualSelectionCookie: 'locale_manually_set'` - Cookie name defined
**No additional package installation required.**
## Architecture Patterns
### Existing Infrastructure Overview
```
plugins/golem15/translate/
|-- classes/
| |-- LocaleMiddleware.php # Priority cascade: URL > User > Session > Browser > Default
| |-- Translator.php # Singleton, setLocale/getLocale/loadLocaleFromRequest
|-- components/
| |-- LocalePicker.php # onSwitchLocale handler, forceUrl property
| |-- AlternateHrefLangElements.php # Generates hreflang tags
|-- config/config.php # browserDetection settings
|-- routes.php # Registers /{locale}/* route groups
```
### Pattern 1: Locale Priority Cascade (Existing)
**What:** The middleware implements a clear priority order for locale resolution.
**When to use:** Already runs on every CMS request.
**Implementation (from LocaleMiddleware.php):**
```php
// Priority 1: URL prefix (explicit override)
if (!$translator->loadLocaleFromRequest()) {
// Priority 2: Authenticated user's preferred_locale from DB
if (!$this->loadLocaleFromUser($translator)) {
// Priority 3: Session (previous selection)
$localeLoaded = $translator->loadLocaleFromSession();
// Priority 4: Browser Accept-Language (only without manual selection cookie)
if (!$localeLoaded && !$this->hasManualLocaleSelection($request)) {
$localeLoaded = $this->loadLocaleFromBrowser($translator, $request);
}
// Priority 5: Default locale fallback
if (!$localeLoaded) {
$translator->setLocale($translator->getDefaultLocale());
}
}
}
```
### Pattern 2: URL Prefix Routing (Existing)
**What:** Routes with locale prefix are registered dynamically.
**When to use:** Automatically for any URL with `/pl/` or `/de/` prefix.
**Implementation (from routes.php):**
```php
$locale = $translator->getLocale();
Route::group(['prefix' => $locale, 'middleware' => 'web'], function () {
Route::any('{slug?}', 'Cms\Classes\CmsController@run')->where('slug', '(.*)?');
});
```
### Pattern 3: LocalePicker Component Usage
**What:** Provides locale switching with proper URL handling.
**When to use:** In theme partials for language switcher.
**Example:**
```twig
{# In layout or header partial #}
{% component 'localePicker' %}
{# Custom implementation #}
<div class="language-switcher">
<button class="language-current">{{ activeLocaleName }}</button>
<ul class="language-list">
{% for code, name in locales %}
<li>
<a href="#"
data-request="localePicker::onSwitchLocale"
data-request-data="locale: '{{ code }}'">
{{ name }}
</a>
</li>
{% endfor %}
</ul>
</div>
```
### Pattern 4: Manual Selection Cookie
**What:** Cookie that prevents browser auto-detection after manual selection.
**Gap Found:** Cookie is READ by LocaleMiddleware but NOT SET anywhere.
**Required Enhancement:**
```php
// In LocalePicker::onSwitchLocale() - set cookie
Cookie::queue(
Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'),
'1',
Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600) // 1 year
);
// In routes.php - set cookie when URL prefix detected
if ($translator->loadLocaleFromRequest()) {
Cookie::queue('locale_manually_set', '1', 525600);
}
```
### Pattern 5: Hreflang Implementation
**What:** Automatic generation of alternate language links for SEO.
**When to use:** In all layouts' `<head>` section.
**Example:**
```twig
{# In layout head #}
{% component 'alternateHrefLangElements' %}
{# Generates #}
<link rel="alternate" hreflang="en" href="https://quotify.pro/jobs">
<link rel="alternate" hreflang="pl" href="https://quotify.pro/pl/jobs">
<link rel="alternate" hreflang="de" href="https://quotify.pro/de/jobs">
```
### Anti-Patterns to Avoid
- **Auto-redirect without user consent:** Never silently redirect users based on browser language. Use suggestion banners instead.
- **Coupling locale with location/currency:** Locale is language preference only. Don't assume location from language.
- **Modal popups for language selection:** Use non-modal banners - users instinctively dismiss modals.
- **Overwriting DB preference on URL prefix visit:** URL prefix should only affect the current session, not update the user's stored preference.
## Don't Hand-Roll
Problems that look simple but have existing solutions:
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Locale detection from URL | Custom URL parser | `Translator::loadLocaleFromRequest()` | Handles edge cases, segment extraction |
| Accept-Language parsing | Custom header parser | `LocaleMiddleware::parseAcceptLanguage()` | Handles quality values, malformed headers |
| Locale validation | Custom list check | `Locale::isValid($locale)` | Uses cached locale list |
| Session persistence | Custom session handling | `Translator::setLocale($locale, true)` | Second param enables session storage |
| Hreflang generation | Custom link builder | `AlternateHrefLangElements` component | Handles static pages, CMS pages, URL params |
**Key insight:** The Golem15\Translate plugin has comprehensive infrastructure. The only gaps are: (1) cookie setting on manual selection, (2) UI components for the theme, (3) user preference sync to DB.
## Common Pitfalls
### Pitfall 1: Cookie Not Being Set on Manual Selection
**What goes wrong:** Users who switch language via picker or URL prefix still get browser detection on next visit.
**Why it happens:** LocaleMiddleware reads `locale_manually_set` cookie but it's never written.
**How to avoid:** Add cookie setting in LocalePicker::onSwitchLocale() and routes.php when URL prefix is detected.
**Warning signs:** Browser detection triggers even after user has explicitly chosen a language.
### Pitfall 2: User Preference Not Syncing to Database
**What goes wrong:** Logged-in users switch language but it's not saved to their profile.
**Why it happens:** LocalePicker only sets session, doesn't update `User::preferred_locale`.
**How to avoid:** Extend onSwitchLocale to update `Auth::getUser()->preferred_locale` if authenticated.
**Warning signs:** User logs in on another device and sees wrong language.
### Pitfall 3: Hreflang Without x-default
**What goes wrong:** Search engines don't know which URL to use for unmatched languages.
**Why it happens:** Missing x-default hreflang tag.
**How to avoid:** Ensure AlternateHrefLangElements or custom implementation includes `<link rel="alternate" hreflang="x-default">` pointing to default locale URL.
**Warning signs:** Search Console shows hreflang warnings.
### Pitfall 4: Modal Popup for Language Suggestion
**What goes wrong:** Users instinctively close modals without reading.
**Why it happens:** Using modal instead of dismissable banner.
**How to avoid:** Use sticky top/bottom banner that doesn't block interaction.
**Warning signs:** High dismissal rates, low language switch conversions.
### Pitfall 5: Snowboard data-request-success
**What goes wrong:** Callbacks fail silently.
**Why it happens:** WinterCMS Snowboard framework removed `data-request-success` as unsafe (uses eval).
**How to avoid:** Use `Snowboard.request()` with `success` callback function.
**Warning signs:** No callback execution after AJAX request.
## Code Examples
Verified patterns from existing codebase:
### Language Switcher Dropdown (Theme Implementation)
```twig
{# themes/quotify/partials/language-switcher.htm #}
{% set locales = localePicker.locales %}
{% set activeLocale = localePicker.activeLocale %}
{% set activeLocaleName = localePicker.activeLocaleName %}
<div class="language-switcher" data-language-switcher>
<button type="button" class="language-switcher-toggle" aria-expanded="false" aria-haspopup="true">
<span class="language-switcher-label">{{ activeLocaleName }}</span>
<svg class="language-switcher-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"/>
</svg>
</button>
<ul class="language-switcher-list" role="menu">
{% for code, name in locales %}
<li role="none">
<a href="#" role="menuitem"
class="language-switcher-option {% if code == activeLocale %}active{% endif %}"
data-request="localePicker::onSwitchLocale"
data-request-data="locale: '{{ code }}'">
{{ name }}
</a>
</li>
{% endfor %}
</ul>
</div>
```
### Language Suggestion Banner Component
```php
// plugins/golem15/translate/components/LocaleSuggestionBanner.php
namespace Golem15\Translate\Components;
use Cms\Classes\ComponentBase;
use Golem15\Translate\Classes\Translator;
use Golem15\Translate\Models\Locale;
use Request;
use Config;
class LocaleSuggestionBanner extends ComponentBase
{
public function componentDetails(): array
{
return [
'name' => 'Locale Suggestion Banner',
'description' => 'Shows banner when browser language differs from current page'
];
}
public function onRun()
{
$this->page['showBanner'] = $this->shouldShowBanner();
$this->page['suggestedLocale'] = $this->getSuggestedLocale();
$this->page['suggestedLocaleName'] = $this->getSuggestedLocaleName();
}
protected function shouldShowBanner(): bool
{
// Don't show if manually set
if (Request::cookie('locale_manually_set')) {
return false;
}
// Don't show if dismissed this session
if (session('locale_banner_dismissed')) {
return false;
}
// Don't show if 1-week dismissal cookie exists
if (Request::cookie('locale_banner_dismissed')) {
return false;
}
// Check if browser language differs from current
return $this->getSuggestedLocale() !== null;
}
protected function getSuggestedLocale(): ?string
{
$browserLocale = $this->detectBrowserLocale();
$currentLocale = Translator::instance()->getLocale();
if ($browserLocale && $browserLocale !== $currentLocale) {
return $browserLocale;
}
return null;
}
protected function detectBrowserLocale(): ?string
{
$acceptLanguage = Request::header('Accept-Language');
if (!$acceptLanguage) return null;
$enabledLocales = array_keys(Locale::listEnabled());
$candidates = explode(',', $acceptLanguage);
foreach ($candidates as $candidate) {
$code = strtolower(substr(trim(explode(';', $candidate)[0]), 0, 2));
if (in_array($code, $enabledLocales)) {
return $code;
}
}
return null;
}
protected function getSuggestedLocaleName(): ?string
{
$locale = $this->getSuggestedLocale();
if (!$locale) return null;
$locales = Locale::listEnabled();
return $locales[$locale] ?? null;
}
public function onDismissBanner()
{
session(['locale_banner_dismissed' => true]);
// Set 1-week dismissal cookie
\Cookie::queue('locale_banner_dismissed', '1', 60 * 24 * 7); // 7 days
return ['success' => true];
}
public function onSwitchToSuggested()
{
$locale = post('locale');
if (!Locale::isValid($locale)) {
return;
}
Translator::instance()->setLocale($locale, true);
// Set manual selection cookie
\Cookie::queue(
Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'),
'1',
Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600)
);
return \Redirect::refresh();
}
}
```
### Banner Partial Template
```twig
{# components/localesuggestionbanner/default.htm #}
{% if showBanner and suggestedLocale %}
<div class="locale-suggestion-banner" data-locale-banner role="alert">
<div class="container">
<div class="locale-suggestion-content">
<p class="locale-suggestion-text">
{% if suggestedLocale == 'pl' %}
{{ 'Ta strona jest dostepna po polsku'|_ }}
{% elseif suggestedLocale == 'de' %}
{{ 'Diese Seite ist auf Deutsch verfügbar'|_ }}
{% else %}
{{ 'This page is available in'|_ }} {{ suggestedLocaleName }}
{% endif %}
</p>
<div class="locale-suggestion-actions">
<button type="button"
class="btn btn-sm btn-primary"
data-request="{{ __SELF__ }}::onSwitchToSuggested"
data-request-data="locale: '{{ suggestedLocale }}'">
{% if suggestedLocale == 'pl' %}Przełącz na polski{% elseif suggestedLocale == 'de' %}Zu Deutsch wechseln{% else %}Switch{% endif %}
</button>
<button type="button"
class="btn btn-sm btn-link"
data-request="{{ __SELF__ }}::onDismissBanner"
data-request-update="{ '{{ __SELF__ }}': '@self' }">
<svg class="icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"/>
</svg>
</button>
</div>
</div>
</div>
</div>
{% endif %}
```
### Syncing Preference to User Database
```php
// Extend LocalePicker::onSwitchLocale in Plugin.php boot()
\Golem15\Translate\Components\LocalePicker::extend(function($component) {
$component->bindEvent('component.beforeRunAjaxHandler', function($handler) use ($component) {
if ($handler === 'onSwitchLocale') {
$locale = post('locale');
// Set manual selection cookie
\Cookie::queue(
\Config::get('golem15.translate::browserDetection.manualSelectionCookie', 'locale_manually_set'),
'1',
\Config::get('golem15.translate::browserDetection.manualSelectionExpiry', 525600)
);
// Update user preference in DB if logged in
if (\Auth::check()) {
$user = \Auth::getUser();
$user->preferred_locale = $locale;
$user->save();
}
}
});
});
```
### Account Settings Language Preference Section
```twig
{# Add to account/update.htm - new section #}
<div class="account-section">
<div class="account-section-header">
<h3 class="account-section-title">{{ 'Language Preference'|_ }}</h3>
<p class="account-section-description">{{ 'Choose your preferred language for the site.'|_ }}</p>
</div>
<div class="account-section-content">
<div class="form-group">
<label for="preferredLocale" class="form-label">{{ 'Preferred Language'|_ }}</label>
<select name="preferred_locale" id="preferredLocale" class="form-control">
{% set locales = {'en': 'English', 'pl': 'Polski', 'de': 'Deutsch'} %}
{% for code, name in locales %}
<option value="{{ code }}" {% if user.preferred_locale == code %}selected{% endif %}>
{{ name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
```
## State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Modal popups for language | Non-modal sticky banners | 2022+ | Better UX, lower dismissal rates |
| Auto-redirect on language detection | Suggestion banner + user choice | Best practice | SEO-safe, respects user intent |
| Coupling language/region/currency | Decoupled preferences | Industry standard | Users can mix preferences |
**Deprecated/outdated:**
- `data-request-success` attribute: Removed from WinterCMS Snowboard as unsafe (eval). Use `Snowboard.request()` with callback.
## Open Questions
Things that couldn't be fully resolved:
1. **x-default hreflang tag**
- What we know: AlternateHrefLangElements generates per-locale links
- What's unclear: Whether it includes x-default automatically
- Recommendation: Verify in testing, add manually if missing
2. **URL prefix cookie setting location**
- What we know: Cookie should be set when URL has locale prefix
- What's unclear: Best place to intercept (routes.php vs middleware)
- Recommendation: Test in routes.php first; middleware as fallback
## Sources
### Primary (HIGH confidence)
- `/plugins/golem15/translate/classes/LocaleMiddleware.php` - Priority cascade implementation
- `/plugins/golem15/translate/classes/Translator.php` - Locale state management
- `/plugins/golem15/translate/components/LocalePicker.php` - Component implementation
- `/plugins/golem15/translate/components/AlternateHrefLangElements.php` - Hreflang generation
- `/plugins/golem15/translate/config/config.php` - Default configuration
- `/config/golem15/translate/config.php` - Project-specific config
- `/plugins/golem15/user/models/User.php` - preferred_locale field
### Secondary (MEDIUM confidence)
- [Smashing Magazine: Designing Better Language Selector](https://www.smashingmagazine.com/2022/05/designing-better-language-selector/) - UX patterns for language selectors
- [Google Search Central: Localized Versions](https://developers.google.com/search/docs/specialty/international/localized-versions) - Hreflang best practices
- [TranslatePress: Automatic User Language Detection](https://translatepress.com/docs/addons/automatic-user-language-detection/) - Pop-up vs redirect patterns
### Tertiary (LOW confidence)
- WebSearch results on cookie banner patterns - General UX guidance
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH - Direct inspection of existing codebase
- Architecture: HIGH - Verified from actual plugin implementation
- Pitfalls: HIGH - Identified from code gaps and CLAUDE.md constraints
- Code examples: MEDIUM - Based on existing patterns, needs testing
**Research date:** 2026-02-02
**Valid until:** 2026-03-02 (30 days - stable infrastructure)

View File

@@ -0,0 +1,146 @@
---
phase: 15-locale-detection-routing
verified: 2026-02-02T01:12:14Z
status: passed
score: 11/11 must-haves verified
---
# Phase 15: Locale Detection & Routing Verification Report
**Phase Goal:** URL-based locale switching (/pl/, /de/), browser detection for first visit, persistent user preference, language switcher UI
**Verified:** 2026-02-02T01:12:14Z
**Status:** passed
**Re-verification:** No - initial verification
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| 1 | Manual locale selection via LocalePicker sets 1-year cookie | VERIFIED | `plugins/golem15/quotify/Plugin.php:503-507` - Cookie::queue with manualSelectionCookie config key, 525600 min expiry |
| 2 | URL prefix visit (/pl/, /de/) sets manual selection cookie | VERIFIED | `plugins/golem15/translate/routes.php:31-35` - Cookie::queue after loadLocaleFromRequest() succeeds |
| 3 | Logged-in user's locale switch updates their DB preference | VERIFIED | `plugins/golem15/quotify/Plugin.php:510-513` - Auth::getUser()->preferred_locale = $locale; $user->save() |
| 4 | Browser detection skipped when manual selection cookie exists | VERIFIED | `plugins/golem15/translate/classes/LocaleMiddleware.php:214-228` - hasManualLocaleSelection() checks cookie |
| 5 | Language switcher dropdown visible in header | VERIFIED | `themes/quotify/partials/header.htm:15-17` - includes language-switcher partial in header-actions |
| 6 | Clicking language option switches locale and reloads page | VERIFIED | `themes/quotify/partials/language-switcher.htm:17` - data-request="localePicker::onSwitchLocale" |
| 7 | Current language name shown in native form (Polski, Deutsch, English) | VERIFIED | `themes/quotify/partials/language-switcher.htm:7` - displays activeLocaleName from LocalePicker |
| 8 | All pages have hreflang tags for SEO | VERIFIED | All 3 layouts (default, dashboard, empty) have alternateHrefLangElements component and {% component 'alternateHrefLangElements' %} |
| 9 | Account settings has language preference section | VERIFIED | `themes/quotify/partials/account/update.htm:97-118` - Language Preference section with dropdown |
| 10 | Logged-in users can select preferred language in profile | VERIFIED | `themes/quotify/partials/account/update.htm:107` - name="preferred_locale" select element in form_ajax onUpdate |
| 11 | Language suggestion banner appears when browser language differs | VERIFIED | `plugins/golem15/translate/components/LocaleSuggestionBanner.php` - shouldShowBanner() logic with Accept-Language detection |
| 12 | Dismissing banner sets cookie to prevent showing again | VERIFIED | `plugins/golem15/translate/components/LocaleSuggestionBanner.php:96` - locale_banner_dismissed cookie, 1 week expiry |
**Score:** 11/11 truths verified (12 listed but 11-12 are sub-items of the same truth)
### Required Artifacts
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `plugins/golem15/quotify/Plugin.php` | LocalePicker extension with cookie and DB sync | VERIFIED | extendLocalePicker() method at lines 482-517 |
| `plugins/golem15/translate/routes.php` | Cookie setting on URL prefix detection | VERIFIED | Cookie::queue at lines 31-35 after locale loaded |
| `themes/quotify/partials/language-switcher.htm` | Dropdown language switcher UI | VERIFIED | 58 lines, accessible dropdown with ARIA, JS toggle |
| `themes/quotify/partials/header.htm` | Header with language switcher | VERIFIED | Lines 14-17 include language-switcher partial |
| `themes/quotify/layouts/default.htm` | Layout with hreflang and LocalePicker | VERIFIED | Components registered, hreflang output at line 23 |
| `themes/quotify/layouts/dashboard.htm` | Layout with hreflang and LocalePicker | VERIFIED | Components registered lines 7-9, hreflang output at line 22 |
| `themes/quotify/layouts/empty.htm` | Layout with hreflang and LocalePicker | VERIFIED | Components registered lines 4-5, hreflang output at line 15 |
| `themes/quotify/partials/account/update.htm` | Language preference section in account settings | VERIFIED | Language Preference section lines 97-118 with preferred_locale field |
| `plugins/golem15/translate/components/LocaleSuggestionBanner.php` | Component for language mismatch detection | VERIFIED | 120 lines, shouldShowBanner(), getSuggestedLocale(), onDismissBanner() |
| `plugins/golem15/translate/components/localesuggestionbanner/default.htm` | Banner template | VERIFIED | 35 lines, conditional banner with localized text |
| `themes/quotify/assets/css/components/language-switcher.css` | Language switcher styles | VERIFIED | 115 lines, responsive design |
| `themes/quotify/assets/css/components/locale-banner.css` | Banner styles | VERIFIED | 85 lines, sticky positioning, gradient background |
| `themes/quotify/assets/css/app.css` | Imports for both CSS files | VERIFIED | Lines 14-15 import language-switcher.css and locale-banner.css |
### Key Link Verification
| From | To | Via | Status | Details |
|------|-----|-----|--------|---------|
| LocalePicker::onSwitchLocale | Cookie::queue | Component extension in Plugin.php boot() | WIRED | `Plugin.php:503` - Cookie::queue call inside bindEvent handler |
| LocalePicker::onSwitchLocale | User::preferred_locale | Auth::getUser()->save() | WIRED | `Plugin.php:510-513` - Auth check + save |
| language-switcher.htm | localePicker::onSwitchLocale | data-request attribute | WIRED | `language-switcher.htm:17` - data-request present |
| routes.php | Cookie::queue | After loadLocaleFromRequest() | WIRED | `routes.php:31-35` - Cookie set after locale loaded |
| LocaleMiddleware | hasManualLocaleSelection | Cookie read in detection cascade | WIRED | `LocaleMiddleware.php:34` - calls hasManualLocaleSelection |
| default.htm | alternateHrefLangElements | Component registration | WIRED | Component registered line 8, output line 23 |
| account/update.htm | Account::onUpdate | Form submission with preferred_locale | WIRED | form_ajax('onUpdate') at line 95, preferred_locale field at line 107 |
| LocaleSuggestionBanner | Accept-Language detection | detectBrowserLocale() | WIRED | `LocaleSuggestionBanner.php:63-79` - Request::header parsing |
| onDismissBanner | Cookie::queue | locale_banner_dismissed cookie | WIRED | `LocaleSuggestionBanner.php:96` - 1-week cookie set |
### Requirements Coverage
Phase 15 requirements from ROADMAP.md:
- URL-based locale switching (/pl/, /de/) - SATISFIED
- Browser detection for first visit - SATISFIED
- Persistent user preference - SATISFIED
- Language switcher UI - SATISFIED
### Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
|------|------|---------|----------|--------|
| - | - | None found | - | - |
No stub patterns, placeholder content, or empty implementations detected in any verified artifacts.
### Human Verification Required
### 1. Visual Language Switcher Appearance
**Test:** Navigate to homepage, check header area
**Expected:** Language switcher dropdown visible near right side of header, styled consistently with site design
**Why human:** Visual appearance and styling cannot be verified programmatically
### 2. Language Switch Flow
**Test:** Click language switcher, select "Polski" or "Deutsch"
**Expected:** Page reloads in selected language, switcher shows new language name
**Why human:** Page reload and full interaction flow requires browser testing
### 3. Mobile Language Switcher
**Test:** View site on mobile viewport (< 768px)
**Expected:** Language label hidden, globe icon visible, touch targets adequate
**Why human:** Responsive behavior requires actual device/viewport testing
### 4. hreflang Tags in Source
**Test:** View page source, search for "hreflang"
**Expected:** `<link rel="alternate" hreflang="en/pl/de" href="...">` tags present for all locales
**Why human:** Need to verify rendered HTML output
### 5. Account Settings Language Preference
**Test:** Log in, go to account settings, select "Account Info" tab
**Expected:** Language Preference section visible with dropdown showing en/pl/de options
**Why human:** Requires authenticated session and UI interaction
### 6. Language Suggestion Banner
**Test:** Clear cookies, set browser language to German, visit English page
**Expected:** Banner appears at top: "Diese Seite ist auf Deutsch verfugbar" with switch button
**Why human:** Requires browser language configuration and cookie clearing
### 7. Banner Dismissal Persistence
**Test:** Dismiss the suggestion banner, refresh page
**Expected:** Banner does not reappear (cookie prevents)
**Why human:** Requires cookie behavior verification
### Gaps Summary
No gaps found. All must-haves verified as implemented:
**Plan 15-01 (Core Locale Infrastructure):**
- LocalePicker extension in Quotify Plugin.php sets 1-year cookie on manual locale switch
- routes.php sets cookie when URL prefix detected
- LocaleMiddleware correctly skips browser detection when cookie present
- Logged-in users' preferences saved to database
**Plan 15-02 (Language Switcher UI):**
- Language switcher partial created with accessible dropdown
- Integrated into header across all pages
- All three layouts register localePicker and alternateHrefLangElements components
- hreflang tags output in head section of all layouts
**Plan 15-03 (Account Settings & Suggestion Banner):**
- Language Preference section added to account settings
- LocaleSuggestionBanner component created with browser detection
- Banner template with localized text in target language
- Dismissal cookie (1 week) prevents repeated banner display
---
*Verified: 2026-02-02T01:12:14Z*
*Verifier: Claude (gsd-verifier)*