Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions app/Http/Controllers/Settings/LocaleController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

namespace App\Http\Controllers\Settings;

use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\LocaleUpdateRequest;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Cookie;
use Inertia\Inertia;

class LocaleController extends Controller
{
public function update(LocaleUpdateRequest $request): RedirectResponse
{
$locale = (string) $request->validated('locale');

if ($request->user() && config('localization.user_preference.enabled')) {
$request->user()->forceFill([
(string) config('localization.user_preference.column', 'locale') => $locale,
])->save();
}

Cookie::queue(Cookie::make(
(string) config('localization.cookie.name', 'locale'),
$locale,
(int) config('localization.cookie.duration_minutes', 525600),
null,
null,
null,
false,
false,
'lax',
));

Inertia::flash('toast', ['type' => 'success', 'message' => __('Language preference updated.')]);

return back();
}
}
7 changes: 7 additions & 0 deletions app/Http/Middleware/HandleInertiaRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ public function share(Request $request): array
return [
...parent::share($request),
'name' => config('app.name'),
'locale' => [
'active' => app()->getLocale(),
'supported' => config('localization.supported_locales'),
'default' => config('localization.default_locale'),
'fallback' => config('localization.fallback_locale'),
'usesRoutePrefixes' => config('localization.use_locale_route_prefixes'),
],
'auth' => [
'user' => $request->user(),
],
Expand Down
58 changes: 58 additions & 0 deletions app/Http/Middleware/HandleLocale.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;

class HandleLocale
{
/**
* Handle an incoming request.
*
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
App::setLocale($this->resolveLocale($request));

return $next($request);
}

protected function resolveLocale(Request $request): string
{
$supportedLocales = $this->supportedLocales();

$candidates = [
$request->user()?->getAttribute((string) config('localization.user_preference.column', 'locale')),
$request->cookies->get((string) config('localization.cookie.name', 'locale')),
$request->headers->has('Accept-Language') ? $request->getPreferredLanguage($supportedLocales) : null,
config('localization.default_locale'),
config('app.locale'),
];

foreach ($candidates as $candidate) {
if (is_string($candidate) && in_array($candidate, $supportedLocales, true)) {
return $candidate;
}
}

return 'en';
}

/**
* @return list<string>
*/
protected function supportedLocales(): array
{
$locales = config('localization.supported_locales', []);

if (! is_array($locales)) {
return [];
}

return array_keys($locales);
}
}
30 changes: 30 additions & 0 deletions app/Http/Requests/Settings/LocaleUpdateRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Http\Requests\Settings;

use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class LocaleUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}

/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'locale' => ['required', 'string', Rule::in(array_keys(config('localization.supported_locales', [])))],
];
}
}
11 changes: 9 additions & 2 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
Expand All @@ -19,6 +20,7 @@
* @property int $id
* @property string $name
* @property string $email
* @property string $locale
* @property Carbon|null $email_verified_at
* @property string $password
* @property string|null $two_factor_secret
Expand All @@ -28,9 +30,9 @@
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
#[Fillable(['name', 'email', 'password'])]
#[Fillable(['name', 'email', 'locale', 'password'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
class User extends Authenticatable implements PasskeyUser
class User extends Authenticatable implements HasLocalePreference, PasskeyUser
{
/** @use HasFactory<UserFactory> */
use HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
Expand All @@ -48,4 +50,9 @@ protected function casts(): array
'two_factor_confirmed_at' => 'datetime',
];
}

public function preferredLocale(): string
{
return $this->locale;
}
}
4 changes: 3 additions & 1 deletion bootstrap/app.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\HandleLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
Expand All @@ -15,10 +16,11 @@
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
$middleware->encryptCookies(except: ['appearance', 'locale', 'sidebar_state']);

$middleware->web(append: [
HandleAppearance::class,
HandleLocale::class,
HandleInertiaRequests::class,
AddLinkHeadersForPreloadedAssets::class,
]);
Expand Down
79 changes: 79 additions & 0 deletions config/localization.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

return [

/*
|--------------------------------------------------------------------------
| Supported Locales
|--------------------------------------------------------------------------
|
| DDS publishes content in English and Dutch. The public route structure
| stays unprefixed in phase 1; locale choice is a content concern, not a
| URL segment concern.
|
*/

'supported_locales' => [
'en' => [
'name' => 'English',
'native_name' => 'English',
],
'nl' => [
'name' => 'Nederlands',
'native_name' => 'Nederlands',
],
],

'default_locale' => env('APP_LOCALE', 'en'),

'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),

'use_locale_route_prefixes' => false,

'cookie' => [
'name' => 'locale',
'duration_minutes' => 60 * 24 * 365,
],

'user_preference' => [
'enabled' => true,
'column' => 'locale',
],

/*
|--------------------------------------------------------------------------
| Translatable Content Fields
|--------------------------------------------------------------------------
|
| Phase 1 stores translatable content in JSON columns keyed by locale, for
| example: {"en": "Title", "nl": "Titel"}. Admin forms should render one
| field per supported locale for these attributes and persist the combined
| locale map back to the JSON column.
|
*/

'translatable_content' => [
'storage' => 'json_by_locale',
'required_locale' => 'en',
'fallback_locale' => 'en',
],

/*
|--------------------------------------------------------------------------
| Frontend Translation Bundles
|--------------------------------------------------------------------------
|
| When React UI strings become translatable, store JSON bundles using this
| shape: resources/js/locales/{domain}/{locale}/{namespace}.json.
| Examples: frontend/nl/navigation.json, backend/en/settings.json,
| global/nl/validation.json.
|
*/

'frontend_translations' => [
'path_pattern' => 'resources/js/locales/{domain}/{locale}/{namespace}.json',
'domains' => ['frontend', 'backend', 'global'],
'namespace_separator' => '/',
],

];
1 change: 1 addition & 0 deletions database/factories/UserFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public function definition(): array
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'locale' => 'en',
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public function up(): void
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->string('locale', 5)->default('en');
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
Expand Down
52 changes: 38 additions & 14 deletions docs/backlog/initial-build-backlog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This backlog translates the preparation docs into the first practical implementa
- React, Inertia, TypeScript, and Tailwind;
- DDEV with PostgreSQL;
- custom admin, no Filament in phase 1;
- Dutch-first bilingual content with `nl` and `en`;
- English-default bilingual content with `en` and `nl`;
- no locale-prefixed URLs in phase 1;
- public dated activities are called `Events`;
- trainings are `Event` records with `type = training`;
Expand Down Expand Up @@ -244,11 +244,34 @@ Tasks:

Acceptance criteria:

- app default locale is `nl`;
- app default locale is `en`;
- supported locales are explicit in config;
- no `/nl` or `/en` route prefix is required;
- implementation notes describe how translatable content fields should be stored.

### DDS-006A: Runtime Locale And Translation UX

Goal: turn the locale configuration into a runtime language experience, using useful patterns from NIPKaart without introducing locale-prefixed URLs.

Tasks:

- add request locale middleware that chooses the locale from an authenticated user preference, a guest cookie, browser preference, and finally the configured default;
- keep supported locale validation driven by locale config instead of hardcoded locale lists;
- share the active locale and supported locales with Inertia;
- decide whether users need a persisted `locale` preference in phase 1 or whether guest-cookie behavior is enough until account settings mature;
- add a small language switcher only when there are translated UI strings to switch between;
- define the frontend translation bundle shape for React, likely JSON namespaces grouped by domain such as `frontend`, `backend`, and `global`;
- avoid adding locale route prefixes, redirects, or duplicate page URLs.

Acceptance criteria:

- the active locale can be resolved per request without changing the URL shape;
- unsupported locale values are rejected or ignored consistently;
- Inertia pages can read the active locale from shared props;
- the selected approach works for both guests and authenticated users;
- frontend translation files have a predictable namespace convention;
- the implementation remains optional for pages that still use plain placeholder copy.

## Epic 3: Core Public Structure

### DDS-007: Public Static Shell Pages
Expand Down Expand Up @@ -523,17 +546,18 @@ Acceptance criteria:
6. DDS-004B
7. DDS-005
8. DDS-006
9. DDS-007
10. DDS-009
11. DDS-010
12. DDS-011
13. DDS-012
14. DDS-013
15. DDS-014
16. DDS-014A
17. DDS-014B
18. DDS-014C
19. DDS-015
20. DDS-016
9. DDS-006A
10. DDS-007
11. DDS-009
12. DDS-010
13. DDS-011
14. DDS-012
15. DDS-013
16. DDS-014
17. DDS-014A
18. DDS-014B
19. DDS-014C
20. DDS-015
21. DDS-016

The public website rebuild can begin once DDS-007 and DDS-010 exist, while admin and import work continue behind it.
Loading