WinterCMS research
This commit is contained in:
@@ -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>
|
||||
@@ -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*
|
||||
@@ -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>
|
||||
@@ -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*
|
||||
@@ -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>
|
||||
@@ -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*
|
||||
@@ -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*
|
||||
@@ -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)
|
||||
@@ -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)*
|
||||
Reference in New Issue
Block a user