Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
eadbb32
Store the user's UI locale in the database
Sep 7, 2026
c83d0f8
Require the locale on registration
Sep 7, 2026
44e5980
Translate every email into the 16 supported locales
Sep 7, 2026
11ceeff
Make Maizzle and full i18n a hard rule for emails
Sep 7, 2026
f7f4cb7
Drop the arrows from email buttons and paint them brand purple
Sep 7, 2026
612b38a
Sync the user's locale to PostHog as a person property
Sep 7, 2026
58aab39
Put a flag language switcher on every logged-out auth screen
Sep 7, 2026
4498f7b
Point the email footer at the new Instagram handle
Sep 7, 2026
fcbbca4
Render preview notifications inside the locale, not just build them
Sep 7, 2026
00d2f0a
Let the email preview target specific templates
Sep 7, 2026
cde7582
Remove the email preview command
Sep 7, 2026
d3ca1aa
Trim the PR: orphan translations, a duplicate import, narrated comments
Sep 7, 2026
e5c5c5e
Drop Accept-Language negotiation and the fallbacks it needed
Sep 7, 2026
2708163
Refactor locale handling in CreateInvite and CreateUser actions
Sep 7, 2026
6dd911a
Keep the guest switcher to translating the page
Sep 7, 2026
b4ffebd
Resolve the locale from the session, and capture it at login too
Sep 8, 2026
552242c
Bring the locale docs back in line with the code
Sep 8, 2026
1843d1a
Only store a login locale when the visitor actually picked one
Sep 8, 2026
76d8278
Flip the document direction when a guest picks Arabic
Sep 8, 2026
0c60dd3
Target the sign-up link by test id in the browser test
Sep 8, 2026
1ae6ba9
Move the profile photo rules into a FormRequest
Sep 8, 2026
acc767a
Re-apply the locale when it changes mid-session
Sep 8, 2026
bcf87c6
Move the locale wiring out of app.ts into language.ts
Sep 8, 2026
56542ce
Keep dates in step with the language
Sep 8, 2026
6dbbc33
Fix the sidebar layout in right-to-left, and localize the whole calendar
Sep 8, 2026
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
115 changes: 115 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,3 +333,118 @@ to `Connected`, because it does so after a real `verify()` call. A successful
token refresh is not that proof — the refresh token being valid says nothing
about whether publishing still works — so `RefreshSocialToken` must not promote,
even though it would let a paused repurpose resume sooner.

## UI locale (`users.locale`)

The user's UI language lives in the database, on `users.locale`, cast to
`App\Enums\User\Locale`. That enum is the single source of truth for the
supported locales — there is no `config/languages.php` any more, and a case is
only valid if `lang/<value>` exists (`LocalizationParityTest` enforces both that
and parity with `ContentLanguage`).

- **There is no `locale` cookie.** The app stored the locale in the database
until March 2026, moved it to a forever cookie, and moved it back here. Do not
reintroduce the cookie: a second source of truth is what made the switcher and
the register page disagree the first time.
- **`SetLocale` has exactly one rule:** an authenticated request renders in
`Auth::user()->locale`, everything else in `Locale::DEFAULT`. It does not look
at the request body, old input or `Accept-Language`. A logged-out visitor
therefore always gets English from the server, including validation messages.
- **The auth switcher is client-side only.** It calls `loadLanguageAsync`, so
changing language on login or register costs no round trip and touches nothing
on the server. `useGuestLocale` holds the choice at module scope so it survives
Inertia navigation between those screens, and it sets `document.documentElement.dir`
from the picked language — for a guest that is the *only* source of direction,
since the middleware renders `htmlDir` from the default on every request.
- **Register submits `locale` as a required hidden field** and creates the user
with it. **Login submits it only once the visitor picks a language** — the
field goes out empty otherwise, and an empty value leaves `users.locale`
untouched. This asymmetry is load-bearing: the login screen always renders in
`Locale::DEFAULT`, so an always-sent field would reset every non-English user
to English on each login. Forgot and reset password do not send it at all.
- Google and GitHub signups store `Locale::DEFAULT`: they have no picker, and the
OAuth callback tells you nothing reliable about the person.

## PostHog person properties

`App\Jobs\PostHog\SyncUser` is the only place that writes person properties, and
the distinction between its two buckets is load-bearing:

- **`$set_once`** — first-touch facts that must never be rewritten: `signed_up_at`
and the attribution keys (`utm_*`, `gclid`, `fbclid`, …). A later sync must not
overwrite where a user originally came from.
- **Top level** — current state, overwritten on every sync: `$email`, `$name`, and
`locale`.

`locale` mirrors `users.locale` and is what the PostHog email automations (the
onboarding cadence and friends) read to decide which translation to send, so it
has to reflect the language the user picked *now* — never `$set_once`. Anything
that changes `users.locale` must dispatch `SyncUser`; `ProfileController@updateLanguage`
does, and registration already does via `CreateUser`.

Do not reach for `$browser_language` / `$browser_language_prefix` instead. They
are captured automatically by posthog-js but only as **event** properties on
`$pageview`, so they cannot segment a person or feed an automation — and they
report the browser's language at that pageview, not the language the user chose.

Before adding a person property, check what the project already has with the
PostHog MCP (`read-data-schema` with `{"kind": "entity_properties", "entity":
"person"}`) rather than guessing a name; overwriting an existing property is
silent and retroactive.

## Emails (Maizzle + i18n)

**Every email the app sends is fully translated into all 16 supported locales,
and every new email must be too.** There is no English-only email left in the
codebase, and adding one is a regression — not a gap to fill in later.

**Every email is built with Maizzle, and every string in it goes through
`__()`.** Both halves are mandatory, with no exceptions for "small",
"transactional", "internal" or "temporary" emails:

- **Maizzle, always.** Email HTML is authored in `maizzle/templates/<slug>.html`
and compiled to `resources/views/mail/<slug>.blade.php` by
`cd maizzle && npm run build`. Never hand-write a Blade view under
`resources/views/mail/`, never use Laravel's markdown mailables, and **never
edit the Blade files** — they are build output and the next build overwrites
them. A one-off email written outside Maizzle loses the shared layout, header,
footer and inlined CSS, and silently drops out of the translation workflow.
- **i18n, always.** No user-visible string may be a literal — not in the
template, not in the Mailable, not in a notification closure. Subject, preview
text, headings, body copy, button labels and footer chrome all resolve through
`__()` / `trans_choice()` against `lang/*/mail.php`, in all 16 locales. A
literal is invisible to `LocalizationParityTest`, so it ships and stays broken.

How that works in practice:

- **Maizzle eats one `{`-level.** Write `@{{ ... }}` in the template to emit Blade
`{{ ... }}`; write `{!! ... !!}` as-is (it passes through via
`posthtml.expressions.unescapeDelimiters`). A `{{ }}` written directly is
evaluated by Maizzle at build time and disappears.
- **Copy lives in the template, not in the Mailable.** Body text is
`@{{ __('mail.<slug>.<key>') }}` inside the template; the Mailable resolves only
the envelope metadata the layout needs — `subject`, `title`, `previewText` — and
otherwise passes **data** (`$workspaceName`, `$endpoint`, `$publishedPlatforms`),
never sentences. Injecting resolved strings as view variables is what the
disconnected-connections email used to do, and it meant every new sentence had
to be threaded through PHP while the template gave no hint it was translatable.
- **One `lang/*/mail.php` block per template**, keyed by the slug with dashes as
underscores (`post-published.html` => `post_published`). Shared chrome (footer
tagline, sign-off) lives under `layout`. Keys go in all 16 locales;
`LocalizationParityTest` fails on drift. Feature lang files must not carry email
copy — `webhooks.mail.*` moved here for that reason.
- **The recipient's locale is automatic.** `User` implements
`HasLocalePreference`, so `Mail::to($user)` and `$user->notify(...)` localize on
their own; never add a `->locale()` call at a send site. Two consequences:
`Mail::to($user->email)` (a bare string) silently loses it, so always pass the
model; and the invite is the one exception — the recipient has no account yet,
so `CreateInvite` explicitly sends in the inviter's locale.
- **`trans_choice` must handle zero.** The last plural segment is `[0,*]`, not
`[2,*]`: `PostAtRisk` can report a count of 0 when rows disappear between
dispatch and send, and an unmatched count renders a stray leading space.

New email checklist: add the template, add the `mail.<slug>` block to all 16
locales, write a Mailable that passes data plus the three metadata strings, send
with `Mail::to($user)`, run the Maizzle build, and cover it with a render test —
`tests/Feature/Mail/MailRenderingTest.php` exists because copy moving into the
view turns a forgotten variable into a runtime-only failure.
115 changes: 115 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,121 @@ One connected identity per social network per workspace is the Cloud default. Th

Self-hosted compose / `.env.example` set this `true`. When the env is unset, the config falls back to `SELF_HOSTED` so existing self-hosted installs keep multiple accounts. Do **not** use `selfHosted` for the occupancy check (observer, Telegram connect, `NetworkConnectGrid`).

## UI locale (`users.locale`)

The user's UI language lives in the database, on `users.locale`, cast to
`App\Enums\User\Locale`. That enum is the single source of truth for the
supported locales — there is no `config/languages.php` any more, and a case is
only valid if `lang/<value>` exists (`LocalizationParityTest` enforces both that
and parity with `ContentLanguage`).

- **There is no `locale` cookie.** The app stored the locale in the database
until March 2026, moved it to a forever cookie, and moved it back here. Do not
reintroduce the cookie: a second source of truth is what made the switcher and
the register page disagree the first time.
- **`SetLocale` has exactly one rule:** an authenticated request renders in
`Auth::user()->locale`, everything else in `Locale::DEFAULT`. It does not look
at the request body, old input or `Accept-Language`. A logged-out visitor
therefore always gets English from the server, including validation messages.
- **The auth switcher is client-side only.** It calls `loadLanguageAsync`, so
changing language on login or register costs no round trip and touches nothing
on the server. `useGuestLocale` holds the choice at module scope so it survives
Inertia navigation between those screens, and it sets `document.documentElement.dir`
from the picked language — for a guest that is the *only* source of direction,
since the middleware renders `htmlDir` from the default on every request.
- **Register submits `locale` as a required hidden field** and creates the user
with it. **Login submits it only once the visitor picks a language** — the
field goes out empty otherwise, and an empty value leaves `users.locale`
untouched. This asymmetry is load-bearing: the login screen always renders in
`Locale::DEFAULT`, so an always-sent field would reset every non-English user
to English on each login. Forgot and reset password do not send it at all.
- Google and GitHub signups store `Locale::DEFAULT`: they have no picker, and the
OAuth callback tells you nothing reliable about the person.

## PostHog person properties

`App\Jobs\PostHog\SyncUser` is the only place that writes person properties, and
the distinction between its two buckets is load-bearing:

- **`$set_once`** — first-touch facts that must never be rewritten: `signed_up_at`
and the attribution keys (`utm_*`, `gclid`, `fbclid`, …). A later sync must not
overwrite where a user originally came from.
- **Top level** — current state, overwritten on every sync: `$email`, `$name`, and
`locale`.

`locale` mirrors `users.locale` and is what the PostHog email automations (the
onboarding cadence and friends) read to decide which translation to send, so it
has to reflect the language the user picked *now* — never `$set_once`. Anything
that changes `users.locale` must dispatch `SyncUser`; `ProfileController@updateLanguage`
does, and registration already does via `CreateUser`.

Do not reach for `$browser_language` / `$browser_language_prefix` instead. They
are captured automatically by posthog-js but only as **event** properties on
`$pageview`, so they cannot segment a person or feed an automation — and they
report the browser's language at that pageview, not the language the user chose.

Before adding a person property, check what the project already has with the
PostHog MCP (`read-data-schema` with `{"kind": "entity_properties", "entity":
"person"}`) rather than guessing a name; overwriting an existing property is
silent and retroactive.

## Emails (Maizzle + i18n)

**Every email the app sends is fully translated into all 16 supported locales,
and every new email must be too.** There is no English-only email left in the
codebase, and adding one is a regression — not a gap to fill in later.

**Every email is built with Maizzle, and every string in it goes through
`__()`.** Both halves are mandatory, with no exceptions for "small",
"transactional", "internal" or "temporary" emails:

- **Maizzle, always.** Email HTML is authored in `maizzle/templates/<slug>.html`
and compiled to `resources/views/mail/<slug>.blade.php` by
`cd maizzle && npm run build`. Never hand-write a Blade view under
`resources/views/mail/`, never use Laravel's markdown mailables, and **never
edit the Blade files** — they are build output and the next build overwrites
them. A one-off email written outside Maizzle loses the shared layout, header,
footer and inlined CSS, and silently drops out of the translation workflow.
- **i18n, always.** No user-visible string may be a literal — not in the
template, not in the Mailable, not in a notification closure. Subject, preview
text, headings, body copy, button labels and footer chrome all resolve through
`__()` / `trans_choice()` against `lang/*/mail.php`, in all 16 locales. A
literal is invisible to `LocalizationParityTest`, so it ships and stays broken.

How that works in practice:

- **Maizzle eats one `{`-level.** Write `@{{ ... }}` in the template to emit Blade
`{{ ... }}`; write `{!! ... !!}` as-is (it passes through via
`posthtml.expressions.unescapeDelimiters`). A `{{ }}` written directly is
evaluated by Maizzle at build time and disappears.
- **Copy lives in the template, not in the Mailable.** Body text is
`@{{ __('mail.<slug>.<key>') }}` inside the template; the Mailable resolves only
the envelope metadata the layout needs — `subject`, `title`, `previewText` — and
otherwise passes **data** (`$workspaceName`, `$endpoint`, `$publishedPlatforms`),
never sentences. Injecting resolved strings as view variables is what the
disconnected-connections email used to do, and it meant every new sentence had
to be threaded through PHP while the template gave no hint it was translatable.
- **One `lang/*/mail.php` block per template**, keyed by the slug with dashes as
underscores (`post-published.html` => `post_published`). Shared chrome (footer
tagline, sign-off) lives under `layout`. Keys go in all 16 locales;
`LocalizationParityTest` fails on drift. Feature lang files must not carry email
copy — `webhooks.mail.*` moved here for that reason.
- **The recipient's locale is automatic.** `User` implements
`HasLocalePreference`, so `Mail::to($user)` and `$user->notify(...)` localize on
their own; never add a `->locale()` call at a send site. Two consequences:
`Mail::to($user->email)` (a bare string) silently loses it, so always pass the
model; and the invite is the one exception — the recipient has no account yet,
so `CreateInvite` explicitly sends in the inviter's locale.
- **`trans_choice` must handle zero.** The last plural segment is `[0,*]`, not
`[2,*]`: `PostAtRisk` can report a count of 0 when rows disappear between
dispatch and send, and an unmatched count renders a stray leading space.

New email checklist: add the template, add the `mail.<slug>` block to all 16
locales, write a Mailable that passes data plus the three metadata strings, send
with `Mail::to($user)`, run the Maizzle build, and cover it with a render test —
`tests/Feature/Mail/MailRenderingTest.php` exists because copy moving into the
view turns a forgotten variable into a runtime-only failure.

## Icons (@tabler/icons-vue)

- This project uses `@tabler/icons-vue` for all icons. NEVER use `lucide-vue-next`.
Expand Down
8 changes: 6 additions & 2 deletions app/Actions/Invite/CreateInvite.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,19 @@ class CreateInvite
{
public static function execute(Workspace $workspace, array $data): Invite
{
$inviter = auth()->user();

$invite = Invite::create([
'account_id' => $workspace->account_id,
'invited_by' => auth()->id(),
'invited_by' => $inviter->id,
'email' => data_get($data, 'email'),
'role' => WorkspaceRole::from(data_get($data, 'role')),
'workspaces' => [$workspace->id],
]);

Mail::to($invite->email)->send(new WorkspaceInviteMail($invite));
Mail::to($invite->email)
->locale($inviter->preferredLocale())
->send(new WorkspaceInviteMail($invite));

return $invite;
}
Expand Down
4 changes: 3 additions & 1 deletion app/Actions/User/CreateUser.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use App\Actions\Workspace\CreateWorkspace;
use App\Enums\Plan\Slug;
use App\Enums\PostHog\UserEvent;
use App\Enums\User\Locale;
use App\Jobs\PostHog\SyncUser;
use App\Models\Account;
use App\Models\Plan;
Expand All @@ -17,7 +18,7 @@
class CreateUser
{
/**
* @param array{name: string, email: string, password?: string, google_id?: string, github_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool, registration_ip?: string|null} $data
* @param array{name: string, email: string, password?: string, google_id?: string, github_id?: string, email_verified_at?: \DateTimeInterface|null, is_invite?: bool, registration_ip?: string|null, locale?: string} $data
* @param array<string, string> $attributionParameters UTM parameters and ad click IDs (gclid, fbclid, etc.) captured before signup
*/
public static function execute(array $data, array $attributionParameters = []): User
Expand Down Expand Up @@ -47,6 +48,7 @@ public static function execute(array $data, array $attributionParameters = []):
'email_verified_at' => data_get($data, 'email_verified_at', $isInviteRegistration ? now() : null),
'account_id' => $account->id,
'registration_ip' => data_get($data, 'registration_ip'),
'locale' => Locale::from(data_get($data, 'locale', Locale::DEFAULT->value)),
], $attributionParameters));

$account->update(['owner_id' => $user->id]);
Expand Down
Loading