Files
summer-phrasebook/docs/research/wintercms/quotifypro-15-i18n-locale-detection/15-03-PLAN.md
2026-02-18 01:31:41 +01:00

14 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
15-locale-detection-routing 03 execute 2
15-01
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
true
truths artifacts key_links
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
path provides contains
themes/quotify/partials/account/update.htm Language preference section in account settings preferred_locale
path provides contains
plugins/golem15/translate/components/LocaleSuggestionBanner.php Component for language mismatch detection shouldShowBanner
path provides contains
plugins/golem15/translate/components/localesuggestionbanner/default.htm Banner template locale-suggestion-banner
from to via pattern
themes/quotify/partials/account/update.htm Account::onUpdate form submission with preferred_locale field name="preferred_locale"
from to via pattern
LocaleSuggestionBanner LocaleMiddleware detection Accept-Language comparison getSuggestedLocale|detectBrowserLocale
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

<execution_context> @/home/jin/.claude/get-shit-done/workflows/execute-plan.md @/home/jin/.claude/get-shit-done/templates/summary.md </execution_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

Task 1: Add language preference section to account settings themes/quotify/partials/account/update.htm 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:

<!-- 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. Account settings page shows Language Preference dropdown with en/pl/de options Logged-in users can select and save their preferred language in account settings

Task 2: Create LocaleSuggestionBanner component plugins/golem15/translate/components/LocaleSuggestionBanner.php plugins/golem15/translate/components/localesuggestionbanner/default.htm Create the LocaleSuggestionBanner component in the Translate plugin.

Create plugins/golem15/translate/components/LocaleSuggestionBanner.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:

{% 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). Component file exists, template file exists, component can be registered in layout LocaleSuggestionBanner component created with browser language detection and dismissible banner

Task 3: Add banner CSS and integrate into layout themes/quotify/assets/css/components/locale-banner.css themes/quotify/assets/css/app.css themes/quotify/layouts/default.htm Create `themes/quotify/assets/css/components/locale-banner.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:

@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:

[localeSuggestionBanner]

Add to body, after flash partial and before page-wrapper:

{% component 'localeSuggestionBanner' %}
CSS file exists, app.css imports it, default layout registers and displays the banner component Locale suggestion banner styled and integrated into default layout 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)

<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>
After completion, create `.planning/phases/15-locale-detection-routing/15-03-SUMMARY.md`