Introduces an internationalization layer so wacrm can ship in multiple
languages — initially en (canonical), es, lt (untranslated mirrors of
en, ready for translators).
Foundation
- next-intl ^4.13 (Next 16 + React 19 compatible)
- src/i18n/{routing,navigation,request}.ts — locale config, locale-aware
Link/router/redirect, server-side message loader
- next.config.ts wired with `createNextIntlPlugin`
- src/app/* moved under src/app/[locale]/ (excluding /api and globals.css)
- [locale]/layout.tsx is now the root layout: <html lang={locale}>,
hasLocale guard, setRequestLocale, NextIntlClientProvider,
generateStaticParams for build-time prerendering of all locales
- middleware.ts composes next-intl's createMiddleware with the existing
Supabase auth check: intl middleware runs first to handle locale
prefix; auth redirects retain the active locale (e.g. signed-out user
on /es/dashboard → /es/login); intl cookies (NEXT_LOCALE) are carried
onto auth-issued responses so the sticky preference survives
Strings extracted (~80 keys across en/es/lt.json)
- common, nav (sidebar + header), roles (Owner/Admin/Agent/Viewer chips)
- auth.login, auth.signup, auth.forgotPassword (all forms + success states)
- join (all four UI states: loading, fail, accept, sign-up CTA + conflict modal)
- localeSwitcher
UI
- New `<LocaleSwitcher />` in the header — globe icon + locale dropdown
using next-intl's useLocale + locale-aware useRouter
Out of scope for this PR (intentional — keeps diff reviewable)
- Dashboard feature pages (dashboard, inbox, contacts, pipelines,
broadcasts, automations, flows, settings) and their components/
namespaces still have hardcoded English; they continue to render
fine because next/link redirects through the locale middleware
- Shared UI a11y strings (dialog close, etc.)
- Translating es.json / lt.json — they currently mirror en.json so the
app runs in any locale without crashes (English fallback)
- Renaming middleware.ts → proxy.ts per Next 16 deprecation
Verification
- npm run build → 62 static pages prerendered across en/es/lt
- npm run typecheck → clean
- npm run test → 366 passing
- npm run lint → 0 errors (18 pre-existing warnings untouched)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR 6 of the multi-user accounts series. First user-facing surface
for the feature — the Members tab in Settings.
What lands
- `src/components/settings/members-tab.tsx` — roster + pending
invitations. Every member can see the roster (read-only).
Admin+ gets an inline role-select dropdown (excluding the
owner row and self), a Remove button (same restrictions), and
the "Pending invitations" section with revoke.
- `src/components/settings/invite-member-dialog.tsx` — two-step
modal. Form (role + expiry + optional label) → result
(read-only URL with copy-to-clipboard + a "Send via WhatsApp"
deep link). The result step is dismissed-once: the plaintext
token is only ever shown here and never resurfaced, so the
copy carries a warning ("Save this link now").
- `src/app/(dashboard)/settings/page.tsx` — adds `members` to
the tab list, gated behind the existing
`profiles.beta_features.includes('account_sharing')` flag
(same column migration 011 added; flip it via Supabase Studio
on a per-profile basis to dogfood). When the flag is off the
tab trigger AND content render nothing — the page degrades
to the original 5-tab layout. A user landing on
`/settings?tab=members` without the flag is silently bounced
to `?tab=profile` rather than seeing an empty pane.
Why a feature flag at all
- Locked decision from the planning phase: ship the schema and
API unconditionally (they're structural, harmless to existing
single-user installs) but keep the *user-visible* invite
surface behind a flag until the full UI series ships (Join
page, sidebar gating, webhook account routing). Mirrors how
migration 011 / Flows beta worked.
Role-gating
- The tab is reachable by any member of an account. Inside the
tab, mutation controls (Invite member button, role dropdown,
Remove button, pending invitations section + revoke buttons)
are wrapped in `<RequireRole min="admin">` or hidden via
`useAuth().canManageMembers` from #172.
- Server-side RPCs (set_member_role, remove_account_member from
#170; invitation create/revoke from #171) double-check role
anyway — the client gate is UX, not security.
Owner row
- Non-editable. Inline role select and Remove button are hidden
on the owner's row across the board. Owner role changes go
through the (deferred) Transfer Ownership flow.
Self row
- "You" badge on the caller's own row. Role select and Remove
are hidden on it — admins can't accidentally demote or remove
themselves, even though the server RPC would also catch it.
Email visibility
- Admin+ sees teammate emails; agent/viewer sees name + avatar
+ role + joined date only. Implemented server-side by the
`/api/account/members` route in #170; the component just
renders `member.email` when present.
UI patterns
- Mirrors existing tag-manager / template-manager card +
divide-y list layout and the slate-900/slate-700/primary
color tokens from the dark-only theme.
- Dialogs follow the established `bg-slate-900 border-slate-700`
pattern. Destructive actions get the `bg-red-600 hover:bg-red-700`
treatment with `Loader2` spinner.
- "Send via WhatsApp" anchor uses `buttonVariants` directly
rather than `<Button asChild>` — the wacrm Button is the
Base UI ButtonPrimitive and has no Radix-style asChild.
- Base UI Select's `onValueChange` is
`(value: string | null) => void`; every consumer guards on
null. We don't expose a clear affordance, so null shouldn't
fire at runtime — but typescript requires the guard.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds; /settings stays static (data
fetches happen client-side via useEffect)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a new "Appearance" tab in Settings with 5 dark color themes
(Violet, Emerald, Cobalt, Amber, Rose). Pick a theme → applied
immediately and persisted to localStorage. Boot script in the root
layout replays the pick before first paint so there's no
flash-of-default on subsequent loads.
Implementation, ordered the way it's read:
1. `globals.css` — 5 `html[data-theme="..."]` blocks extracted
verbatim from the design handoff at project/Color Themes.html.
`:root` mirrors Violet so SSR / first-paint renders correctly
even before the boot script runs. Three new tokens
(`--primary-hover`, `--primary-soft`, `--primary-soft-2`) added
to all themes — used by tinted-primary surfaces.
2. `src/lib/themes.ts` — single source of truth for the catalog
(id, name, tagline, swatch). Adding a theme = append a CSS
block + append an entry here.
3. `src/hooks/use-theme.tsx` — ThemeProvider client component. Reads
what the boot script already applied, syncs across tabs via the
`storage` event, persists to localStorage. Falls back to a no-op
setter when used outside the provider.
4. `src/app/layout.tsx` — inline `beforeInteractive` script that
reads localStorage and sets `documentElement.dataset.theme`
before React hydrates. Knowledge of valid theme IDs comes from
the THEME_IDS constant so adding a theme doesn't silently break
the boot path. Wraps the tree in ThemeProvider.
5. `src/components/settings/appearance-panel.tsx` — picker UI. 5
cards, swatch + name + tagline + a 4-tone preview strip. Active
card carries a primary-tinted ring + "Active" chip.
6. `src/app/(dashboard)/settings/page.tsx` — new "appearance" tab
alongside profile/whatsapp/templates/tags.
7. Key-surface tokenization — replaced hard-coded `violet-*` with
`primary` tokens on the high-visibility surfaces so the theme
switch feels real: sidebar logo + active nav + unread dot +
avatar fallback, header avatar, dashboard-shell loading
spinner, Flows header icon + entry-node picker icon + entry
badge + flash ring, Flows list page (template card hover,
FlowCard icon), Flows editor + runs "back" links, Settings tabs
active state.
NOT touched (intentional follow-ups):
- Per-node-type colors in flow-builder.tsx NODE_META — those are a
deliberate per-type rainbow, not a brand accent.
- The inbox/contacts/pipelines surfaces — many `violet-*` usages
there. Sweep is a contained follow-up; the user explicitly
scoped this PR to "tokens + tab + key surfaces".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Meta's brand-use guidance for third-party products, the registered
trademark symbol (®) should appear on the FIRST mention of "WhatsApp"
on each user-facing page. Subsequent mentions on the same page are
left plain.
Surfaces touched (one-per-page first mention):
- Landing hero badge ("Built on the official WhatsApp® Business API").
- src/lib/seo/site-config.ts: SITE_TAGLINE and SITE_DESCRIPTION
(rendered into <title> and <meta description> on every page).
- src/app/opengraph-image.tsx: hero text in social-share image.
- Settings, Inbox connection banner, Automations page subtitle.
- README.md tagline.
- All public docs pages (docs/README.md, docs/whatsapp-setup.md,
docs/getting-started.md, docs/supabase-setup.md,
docs/environment-variables.md, docs/deployment-hostinger.md,
docs/automations-and-cron.md, docs/architecture.md,
docs/troubleshooting.md).
Out of scope: SITE_KEYWORDS (the meta-keywords array isn't
user-rendered text — putting ® on every keyword would look broken in
the meta tag).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The legal brief on third-party trademark use says: avoid the WhatsApp
teal/green palette and any visual treatment that could read as a
WhatsApp/Meta product. Emerald-500 was used as the primary brand colour
across hero, CTAs, sidebar, charts, badges, and the favicon — close
enough in hue to WhatsApp's signature green to muddy the line.
Swapping to Tailwind violet (which matches the Hostinger brand purple
#7c3aed) makes the visual identity unambiguously ours.
Scope:
- Mechanical Tailwind class swap: emerald-* → violet-* across src/.
- src/app/globals.css: --primary, --ring, --sidebar-primary, --sidebar-ring,
--chart-1 oklch tokens swapped from emerald (oklch 0.696 0.17 162) to
Hostinger-aligned violet (oklch 0.526 0.247 293).
- src/app/icon.tsx: favicon background #10b981 → #7c3aed.
- src/app/opengraph-image.tsx: badge bg, accent text, gradient.
- src/components/landing/hero.tsx + cta-banner.tsx: radial-gradient hex.
- src/components/landing/mock/analytics-mock.tsx: marketing chart line.
- src/components/dashboard/conversations-chart.tsx + response-time-chart.tsx:
"Outgoing" series colour. Distinguishable from "Incoming" blue at a
glance; we can revisit the chart pair later for stronger colourblind
contrast (separate concern).
- README.md: license badge colour parameter.
Out of scope:
- src/components/settings/tag-manager.tsx still offers "Emerald" as one
of the user-pickable contact-tag colours alongside Blue / Yellow / Red
/ etc. — that's a user data choice, not a brand mark.
Contrast check: white text on #7c3aed measures ~4.78:1 (WCAG AA passing
for normal body text). Violet-400 (#a78bfa) on slate-950 measures ~10:1
for accent text, well clear of AA.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the 14 pre-existing lint errors surfaced by React 19's new
hook rules so CI can enforce lint on every PR instead of tolerating
failures. The errors split roughly into three camps:
1) react-hooks/refs — "Cannot access refs during render"
4 occurrences of the "latest ref" pattern that assigned
`ref.current = x` during render. Moved each assignment into a
`useEffect(() => { ref.current = x })` — the canonical React-19
pattern. Consumers only read `.current` inside async callbacks
that run after commit, so the very slight delay to the update is
fine.
Touched: src/hooks/use-realtime.ts (onMessageRef,
onConversationRef), src/components/inbox/message-thread.tsx
(onMessagesLoadedRef), src/components/inbox/conversation-list.tsx
(onConversationsLoadedRef).
2) react-hooks/set-state-in-effect — genuine issues
- contacts/page.tsx: the `setPage(0)` on search change moved from
a useEffect into the `onChange` handler of the search input,
which is the cleaner pattern anyway.
- settings/page.tsx: the local `tab` state + sync effect were
removed entirely; the URL query param is now the single source
of truth, and the `onChange` handler just calls
`router.replace(...)`. Simpler and fixes the lint error.
3) react-hooks/set-state-in-effect — false positives for
load-on-mount and prop-driven sync
Suppressed per-call (or per-block for effects with many setters)
with explanatory comments. These effects use setters inside
async Supabase-query callbacks, not synchronously in the effect
body, so the cascade the rule warns about doesn't apply. Files:
contacts/page.tsx, pipelines/page.tsx, pipelines/deal-form.tsx,
pipelines/pipeline-settings.tsx,
components/inbox/contact-sidebar.tsx.
4) react-hooks/preserve-manual-memoization — one useCallback
Rule complained that the inferred dep was `contact` but the
source dep list was `[contact?.phone]`. Broadened the manual dep
to `[contact]` to match — callback behaviour is unchanged (it
still only reads `contact?.phone`), and the React Compiler can
now preserve the memoization.
And:
- .github/workflows/ci.yml: dropped `continue-on-error: true` on
the lint step. Lint now blocks merge on any new error.
Verified: `npm run lint` → 0 errors (24 warnings remain; all
pre-existing unused-import / missing-dep / <img> warnings that don't
fail CI). `npm run typecheck` clean. Landing, /docs, /login, and
/settings redirects all still respond correctly from the dev server.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a profile tab to /settings with the five things every B2B SaaS
user expects:
- Display name — edits profiles.full_name.
- Avatar — upload / replace / remove, stored in a new `avatars`
Supabase Storage bucket with RLS scoped to the owner's folder.
Migration 008_profile_avatars_storage.sql creates the bucket (public
read, 2 MB cap, image mimes only) and the four storage.objects
policies.
- Email — goes through supabase.auth.updateUser({ email }), which
confirms to both the old and new addresses. UI surfaces a "check
both inboxes" banner after save.
- Password — three-field form with a current-password gate implemented
via a throwaway signInWithPassword() before calling updateUser.
- Active sessions — one-click "sign out of all devices" using
supabase.auth.signOut({ scope: 'global' }), with a confirm dialog.
Plus read-only role, joined date, and user id for operator debugging.
The header avatar and sidebar user row are now both dropdown menus
linking to /settings?tab=profile and /settings?tab=whatsapp; the
settings page reads ?tab= to land on the right tab. useAuth gets a
refreshProfile() callback so the header/sidebar update immediately
after a save without a full reload.
Deferred (separate work): account deletion, 2FA, phone/timezone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>