PR 7 of the multi-user accounts series. Closes the invite loop —
PR 6 lets admins generate links, this PR is what the recipient
sees when they click one.
What lands
- `src/app/join/layout.tsx` — minimal centered dark shell. Sits
outside both `(auth)` and `(dashboard)` route groups because
the page is hybrid: it renders for anonymous AND signed-in
visitors. Reusing either neighbouring layout would funnel one
audience through the wrong middleware redirect.
- `src/app/join/[token]/page.tsx` — client component. Calls
/api/invitations/[token]/peek + supabase.auth.getUser() in
parallel on mount and routes through four UI states:
peek | auth | render
--------------|--------------|----------------------------------
loading | — | spinner
ok:false (any)| — | friendly error + signup/signin CTAs
ok:true | signed out | "Sign up & join" + "Sign in" links
ok:true | signed in | "Accept invitation" button → redeem
Redeem is intentionally manual — auto-accepting on mount would
race with the signup → email-verification → return flow and
hide what's being accepted from the visitor.
AuthContext doesn't reach this route group (AuthProvider lives
inside (dashboard)), so we call Supabase directly here the same
way /login and /signup already do.
After a successful accept, the page does a full reload to
/dashboard (not router.push) so AuthProvider re-fetches the
profile with the new account_id and account_role.
- `src/app/(auth)/signup/page.tsx`:
- Reads `?invite=<token>` via useSearchParams.
- Passes emailRedirectTo: `${origin}/join/<token>` to
supabase.auth.signUp so the verification email returns the
user to the redeem step.
- Re-skins the card header for invite signups ("Create account
& join", different icon).
- Preserves the token through the "Sign in" link and the
"Back to sign in" button on the success screen.
- Default export now wraps the form in `<Suspense>` — Next 16
refuses to prerender pages calling useSearchParams without
it.
- `src/app/(auth)/login/page.tsx`:
- Reads `?invite=<token>`.
- On successful sign-in, routes to /join/<token> instead of
/dashboard.
- Re-skins the card header for invite logins.
- Preserves the token through the "Create account" link.
- Default export wrapped in `<Suspense>` for the same prerender
reason.
- `src/middleware.ts`:
- When an authenticated user hits /login?invite=<t> or
/signup?invite=<t>, redirect to /join/<t> instead of the
default /dashboard. Without this, a forwarded invite link
landing on an already-signed-in browser would silently drop
on /dashboard and the visitor would never see the accept
prompt.
- Both pre-existing redirect cases (no invite present, OR
/forgot-password with an invite — which doesn't make sense)
fall through to /dashboard as before. The search string is
cleared on both paths so we don't leak the invite token
into the dashboard URL bar.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds:
ƒ /join/[token] — dynamic (peek + auth fetch)
○ /login — static, hydrates the form
○ /signup — static, hydrates the form
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR #132 (color themes) only tokenized the high-visibility chrome.
Inbox / contacts / pipelines / broadcasts / automations / auth /
settings sub-forms still had ~150 hard-coded `violet-*` classes
across ~32 files, so picking a non-violet theme felt half-broken —
sidebar and header changed colour, but message bubbles, deal cards,
and the broadcast wizard all stayed violet.
This is the mechanical follow-up: replace every `violet-N(/N)?`
with `primary(/N)?` so the theme picker themes the whole app.
Two non-trivial swaps inside the pass:
- `text-white` on a `bg-primary` button → `text-primary-foreground`.
Emerald and Amber define a *dark* `--primary-foreground` because
their primaries are bright; `text-white` over those would tank
contrast.
- `bg-primary hover:bg-primary` (hover identical to base — original
code had this latent bug from `hover:bg-violet-500`) → adds `/90`
on hover so the lift is visible.
Test update:
- `broadcast-status.test.ts` asserted shade numbers (`bg-X-N/10`).
Relaxed the regex to also accept token form (`bg-X/10`) since
brand-accent status badges now ride the active theme.
Files touched: 49. Lint clean (existing warnings only), 173/173
tests, typecheck + build green.
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>
Drops the "WaCRM" branded name across user-facing surfaces in favour
of a descriptive name. Branded names that imply WhatsApp ownership or
sponsorship are a trademark fair-use risk; a relationship-based name
("CRM Template for WhatsApp") sidesteps that.
Scope:
- Brand text in nav, sidebar, footer, docs shell, mock window chrome.
- SITE_NAME / SITE_DESCRIPTION / legalName in src/lib/seo/site-config.ts.
- README, CONTRIBUTING, docs/, .github/ templates and policies.
- package.json description.
- Auth pages, settings copy, FAQ, code comments.
Out of scope (intentionally — these are infra renames the project
owner should drive separately so links and the package lockfile stay
consistent):
- The github.com/ArnasDon/wacrm repo URL.
- The wacrm.tech domain / homepage field.
- The package.json "name" field.
- The Hostinger application name and Meta system-user name in deploy
docs (existing-deployment instructions).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>