The automation builder exposed several developer-facing fields that
made automations hard to configure from the UI (issue #216):
- Keyword Match parsed/trimmed on every keystroke, so typing a
comma-separated, multi-word list like "SEO, search engine
optimization" was impossible — the comma and trailing space were
stripped immediately.
- Tag Added / Add Tag / Remove Tag asked for a raw tag_id.
- Assign Conversation → Specific agent asked for a raw agent_id.
- Send Template asked for a manually typed template name + language.
Changes:
- Keyword input keeps a local draft and only parses into the keywords
array on blur / Enter, then re-displays the cleaned form.
- New tag / agent / template pickers select existing resources by name
while still storing ids / names internally, so the stored config
shape (and the engine/validation) is unchanged. Resources load once
at the builder root and are shared via context.
- Tags & approved templates load from Supabase (RLS-scoped, matching
the broadcast picker); members load from /api/account/members.
- Every picker falls back to its original raw input when its list is
empty (fresh account / older deployment), so automations stay
authorable. Saved-but-missing ids are preserved when editing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pipeline board uses overflow-x-auto but hid the scrollbar on fine
pointers, assuming the full board always fits on lg+. That assumption
breaks once a pipeline has many stages: columns keep a 260px min-width,
so the row overflows the viewport and the rightmost stage gets clipped
with no scrollbar or affordance to reveal it (issue #214).
Scope the scrollbar-hiding to touch devices (where the snap/peek layout
already hints at more content) and show a thin, themed horizontal
scrollbar on desktop so the overflow is discoverable and usable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migration 017 made domain tables account-scoped: account_id is NOT NULL
and INSERT RLS is WITH CHECK (is_account_member(account_id, ...)). Several
client- and server-side create flows still inserted only user_id, so on a
fresh install they failed with 403 / "new row violates row-level security
policy" / not-null violations.
Resolve account_id from useAuth() (client) or the caller's profile (server)
and include it on every account-scoped insert:
- contacts: contact-form, import-modal, broadcast CSV upsert
- contact_notes: contact-sidebar, contact-detail-view
- tags: tag-manager
- broadcasts: broadcasts/new page, use-broadcast-sending
- deals: deal-form
- automations: POST /api/automations, POST /api/automations/[id]/duplicate
Each path also guards on a missing accountId with a clear message instead
of letting the insert fail opaquely.
pipelines/page.tsx and api/flows/route.ts are intentionally left to the
already-open PR #193, which fixes those same paths (and adds tests).
Verified: a full sweep of every account-scoped insert/upsert across src/
now includes account_id (except the two paths owned by #193). typecheck,
lint (0 errors), and 370 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The multi-user account-sharing surface (Members tab + invite/role
management APIs + /join redeem flow) is complete, so drop the
`account_sharing` beta flag that hid it during development. Mirrors
the Flows soft-GA in 0.2.0.
- settings: Members tab is now always rendered (no beta_features
check, no stale-link fallback). The `profiles.beta_features`
column itself stays for future betas.
- sidebar: the active-account strip no longer keys off the flag.
It now shows whenever the account name differs from the user's
own name — i.e. once you've renamed the account or joined a
shared one — preserving the original "don't duplicate a solo
user's name" behaviour without the flag.
- CHANGELOG/README: document Members as a shipped, all-users
feature.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reported by @ArnasDon: a teammate accepting an invite landed in the
shared account but the WhatsApp Config tab still showed "not
connected." Inbox banner read "WhatsApp not connected" too.
Cause: PR 8 of the multi-user series converted the *webhook* and
the *automations/flows engines* to account-scoped lookups, but
left every user-facing API route + UI page still filtering by
`user_id`. Post-017 `whatsapp_config` and `message_templates` are
account-scoped (one row per account, with `account_id NOT NULL`),
so `.eq('user_id', user.id)` returned zero rows for any teammate
who didn't personally save the config — including everyone who
joined via invite.
This PR completes the conversion across the user-facing surface.
Surface (11 files)
- `src/components/settings/whatsapp-config.tsx` — read accountId
from `useAuth()`; trigger fetch on profile-ready, not just
user-ready; pass accountId through to the supabase query.
- `src/app/(dashboard)/inbox/page.tsx` — resolve account_id from
profiles, query whatsapp_config by account_id for the
connected-banner state.
- `src/app/api/whatsapp/config/route.ts` — GET/POST/DELETE all
resolve account_id from profile. The phone_number_id collision
check (service-role) now compares against `account_id`, not
`user_id` — two accounts still can't share a Meta number, two
teammates in one account share one row by design. INSERT
populates `account_id` (tenancy) + `user_id` (audit).
- `src/app/api/whatsapp/config/verify-registration/route.ts` —
same conversion.
- `src/app/api/whatsapp/send/route.ts` — conversation,
whatsapp_config, message_templates lookups all switch to
account_id; rate-limit key stays per-user.
- `src/app/api/whatsapp/react/route.ts` — conversation +
whatsapp_config switch to account_id.
- `src/app/api/whatsapp/broadcast/route.ts` — whatsapp_config +
message_templates + broadcasts switch to account_id.
- `src/app/api/whatsapp/media/[mediaId]/route.ts` — config
lookup switches to account_id.
- `src/app/api/whatsapp/templates/submit/route.ts` — config
lookup + every message_templates SELECT/upsert now uses
account_id. The upsert ROW gains `account_id` so the
NOT NULL constraint added by migration 017 stops rejecting
new templates (this was an incidental bug — every new
template post-017 would have been broken regardless of
multi-user).
- `src/app/api/whatsapp/templates/sync/route.ts` — same.
- `src/app/api/whatsapp/templates/[id]/route.ts` PATCH +
DELETE — same.
Pattern at every route:
const { data: profile } = await supabase
.from('profiles')
.select('account_id')
.eq('user_id', user.id)
.maybeSingle()
const accountId = profile?.account_id as string | undefined
if (!accountId) return 403
then every downstream `.eq('user_id', user.id)` → `.eq('account_id', accountId)`.
Known follow-up not in this PR
- `message_templates` still has its UNIQUE index on
`(user_id, name, language)` from migration 014; the submit
upsert's `onConflict` targets that same triple. As a result,
two teammates can still INSERT same-named templates without
conflicting. The fix needs a small migration (drop legacy
index, add `(account_id, name, language)` index, switch the
`onConflict` target) — TODO comment left in the route file so
it's not forgotten.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 D — the final pass of the post-review fix series. Bundles the
seven visual / UX polish items the design review flagged.
P1 + P5 — sidebar account strip (`src/components/layout/sidebar.tsx`)
- Owner used to be invisible in the role-chip slot (the previous
check was `accountRole && accountRole !== "owner"`), so owners
looked indistinguishable from admins. Now the chip always
renders, with a per-role icon + colour pulled from a new
`ROLE_CHIP` table.
- Added `title={account.name}` on the truncated name so the full
account name is visible on hover when it's clipped (long names
+ narrow sidebars).
P2 + P3 + P10 — members tab (`members-tab.tsx`)
- **P2** — destructive Remove / Revoke buttons used a neutral
default that only turned red on hover. The destructive intent
was invisible until the user moused over. Both buttons now have
a red default state (`border-red-500/40 bg-red-500/10
text-red-300`) with a darker shade on hover.
- **P3** — role chips were all slate. Replaced with a per-role
colour scale: owner amber (scarce, immutable), admin primary
(significant), agent slate (operational default), viewer muted
slate (read-only). Mirrors the sidebar's `ROLE_CHIP` table so
the two surfaces don't drift; once the surface stabilises this
should hoist into a shared module.
- **P10** — pending invitations section now opens with a one-line
hint: "The plaintext invite URL is only shown once at creation
for security — to re-share, revoke the invite below and create
a new one." Pre-fix, admins kept looking for a "copy link"
button on the pending rows; the design decision (don't store
plaintext) is now self-explanatory.
- Cleanup: removed the now-unused `ROLE_LABELS` constant
(subsumed by `ROLE_CHIP[role].label`).
P4 — amber "Save this link now" callout contrast
- Pre-fix: `bg-amber-500/10 + text-amber-200` on slate-900 was
borderline for WCAG AAA body-text contrast.
- Post-fix: `bg-amber-500/15`, border bumped to `/50`, strong
intro promoted to `amber-100` so the urgent copy reads with
proper weight against the dark backdrop.
P7 — Try-again button on join page server_error
- Pre-fix: the only recourse for a server_error peek was to
follow "Create a new account" / "Sign in" links — both lose
the invite token.
- Post-fix: extracted the peek+auth fetch as `loadPeekAndAuth`,
call it from a new "Try again" primary button on the
server_error card. Other failure reasons (not_found / used /
expired) are terminal for this token so they keep the
signup/sign-in escape hatches; only server_error gets retry.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Closes out the post-review fix series. Combined with PRs A
(#178, backend perf), B (#179, UI blocking), and C (#180,
front-end correctness), every item from the consolidated review
report is addressed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR C of the post-review fix series. Bundles six related front-end
correctness / portability fixes. No behavior change for happy-path
users — these are belt-and-braces against edge cases.
S1 — useCan exhaustiveness (`src/hooks/use-can.ts`)
- Add a `default` branch with `const _: never = action` so adding
a new `CanAction` without a case fails the typecheck instead of
silently returning `undefined` at runtime.
S2 + P8 — GatedButton helper (`src/components/ui/gated-button.tsx`)
- `<Button disabled title="...">` doesn't render a tooltip in
Safari or older Firefox: those browsers don't fire mouse events
on disabled controls, so the title never surfaces. Viewers on
those browsers saw silently-disabled CTAs with no explanation.
- New `<GatedButton canAct={...} gateReason="...">` wraps the
Button in a non-disabled `<span>` that owns the `title`. The
span receives mouseover everywhere; the tooltip works.
- Also subsumes the duplicated `READ_ONLY_TITLE = "..."` constants
across 5 list pages. `gateReason` is the verb phrase that
completes "Read-only — your role can't <gateReason>".
- Migrated call sites: broadcasts / automations / flows / contacts
/ pipelines list pages + inbox composer (Send + Templates
buttons). The composer's textarea keeps its inline title since
the GatedButton pattern only applies to buttons.
S6 — Revert optimistic role-change on error (`members-tab.tsx`)
- Pre-fix: PATCH failure showed a toast and left the local
member-row state with the new role. Next interaction
(re-toggling to the same role) was a no-op via the
`member.role === nextRole` guard at the top, so the row
appeared stuck. Now we capture `previousRole` before the
optimistic flip and revert on both 4xx and network failure.
S7 — Sidebar flag check matches settings (`sidebar.tsx`)
- Pre-fix: sidebar's `accountSharingEnabled` didn't guard on
`profileLoading`, so the account-name strip briefly was hidden
during the initial profile fetch then popped in. Settings page
did this guard correctly. Now they match.
S8 — Referrer-Policy: no-referrer on /join (`join/layout.tsx`)
- The plaintext invite token lives in the URL path. Belt-and-
braces against any future external resource on the join page
leaking the token via the Referer header. Set via Next 16's
`metadata` export → `<meta name="referrer" content="no-referrer">`.
Also marks the route `noindex, nofollow` so a join URL can't end
up in search results.
P9 — Client-side label length validation (`invite-member-dialog.tsx`)
- Server caps `label` at 80 chars and returns 400 on overflow.
Add a `MAX_LABEL_LEN` constant on the client, point both the
`<Input maxLength>` and the pre-submit check at it. Catches the
paste-then-submit path where a paste lands an over-limit string
in state before the keystroke-level cap engages.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean (the new `never` cast catches typos)
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR B of the post-review fix series. Closes the three user-blocking
UI gaps the design / UX review flagged.
M3 — Charlie's 409 redeem dead-end (join page)
- Pre-fix: a user with existing data clicking an invite saw a
3-second toast then the Accept button re-enabled. The same
retry would 409 forever.
- Post-fix: 409 status is detected explicitly and surfaces a
blocking dialog with an explanation + "Sign out & use a
different email" action. The action calls supabase.signOut()
then reloads the page; the page rebuilds on the signed-out
branch with the "Create account & join" CTA. The invite token
stays in the URL through the reload so the recovery is one
click. Non-409 errors still take the regular toast path.
- Files: `src/app/join/[token]/page.tsx` — new state
`conflictMessage`, new handler `handleSignOutAndRetry`, new
Dialog component in the auth-in render branch.
M4 — WhatsApp share message includes the account name
- Pre-fix: "Join our wacrm account using this link…" — generic,
no team context. In multi-team contexts the recipient
couldn't tell which team they were being invited to.
- Post-fix: "Join <Account Name> on wacrm using this link…".
The account name is snapshotted into the `CreatedInvite`
result at creation time so a later rename can't retroactively
rewrite the wa.me message.
- Files: `src/components/settings/invite-member-dialog.tsx` —
read `account` from `useAuth()`, thread `accountName` through
`CreatedInvite`, update `whatsappShareUrl` copy.
S3 — Members tab roster mobile reflow
- Pre-fix: the member row used a single flex-row with a fixed-
width 128px role Select and a 36px Remove button. On a 375px
viewport the name + email got crushed to ~50px.
- Post-fix: at base width the row stacks vertically — identity
(avatar + name + email) on top, actions (role + Remove) below.
At sm+ it returns to the original inline layout. The joined-
date column stays desktop-only.
- Files: `src/components/settings/members-tab.tsx` — `flex-col`
base, `sm:flex-row sm:items-center` for desktop. Actions
wrapped in their own flex container so the role Select and
Remove button cluster together on both breakpoints.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR A of the post-review fix series. Addresses the two must-fix
backend findings (flow-media account scoping; webhook contact
lookup at scale) plus the two should-fix composite-index
additions, all in one migration + two TS files.
Migration 020 (`supabase/migrations/020_account_sharing_followups.sql`)
- Composite partial index `idx_automations_account_active_trigger`
on `automations(account_id, trigger_type) WHERE is_active`.
The 006-era partial index was account-blind so the planner had
to post-filter by account_id on every webhook dispatch. With
100+ admins in a shared account the post-filter step shows up
in query plans; this index answers all three predicates from
one lookup.
- Same shape for flows: `idx_flows_account_active` on
`flows(account_id) WHERE status='active'` — paired to the
`findEntryFlow` query in the runner.
- `flow-media` storage RLS rewritten to allow uploads / updates
/ deletes by any member of the account whose folder name
matches `account-<account_id>/...`. Legacy `<auth.uid()>/...`
paths remain writable by the original uploader so files
uploaded pre-020 don't break. Reads stay public (the bucket
was already public so Meta can fetch URLs without creds).
Webhook contact lookup (`src/app/api/whatsapp/webhook/route.ts`)
- The PR-8 conversion swapped `eq('user_id', userId)` for
`eq('account_id', accountId)` but kept "pull every row, filter
in JS" semantics. That was fine when account = one user but
scales linearly with the team size × per-user contact count.
- Now pre-filters in SQL by the phone's last-8-digit suffix
(mirroring `phonesMatch`'s trunk-prefix tolerance). The
trailing-suffix `like` can't use a B-tree index, but the
account_id filter on top of `idx_contacts_account` keeps
the scan to a small account-scoped subset. Re-applies the
strict `phonesMatch` check in JS on the candidate set for
correctness — typically 0-2 rows, basically free.
Flow-media upload path (`src/components/flows/forms/node-config-form.tsx`)
- Resolves the caller's `profile.account_id` before upload and
pathes the file under `account-<account_id>/...` to match the
new 020 RLS policy. Legacy `<user_id>/...` paths keep working
via the back-compat clause in the policy.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR 9 of the multi-user accounts series — the final piece. Visual
polish so non-owners see consistent disabled states across the
app and so every user always knows which account they're acting in.
Sidebar
- New account-name strip above the user dropdown, gated behind
the `account_sharing` beta flag (same flag the Members tab
from PR 6 reads). For solo users (the default) the account is
named after them, so showing it would just duplicate the user
name below — flag-gating keeps the original layout untouched
for single-user installs.
- When the caller's role is admin/agent/viewer (not owner), the
role is surfaced as a small uppercase chip alongside the
account name so they have an unambiguous signal of their
permission level without opening Settings.
- Nav items stay always-visible per the planning doc's
"show-but-disable" decision — hiding nav rows by role would
make the app feel broken.
Inbox composer
- Send button + Templates button + textarea are disabled for
viewers with a "Read-only — viewers can browse but not reply"
placeholder and tooltip. The existing session-expired branch
still wins when it applies (session-expired is the more
specific condition and surfaces template-only sending).
List-page create buttons
- broadcasts/page.tsx: "New Broadcast" CTA (header + empty
state).
- automations/page.tsx: "Create Automation" CTA.
- flows/page.tsx: "New flow" CTA (header + empty-state).
- contacts/page.tsx: "Add Contact" + "Import" CTAs.
- pipelines/page.tsx: "Add Pipeline" (gates on edit-settings —
pipelines are settings-class under the 017 RLS) and "Add
Deal" (gates on send-messages).
All use `useCan(...)` from PR 5. Disabled buttons get a tooltip
explaining why (`title="Read-only — your role can't ..."`).
Solo users see no visual change — single-owner accounts pass
every capability check.
Why show-but-disable, not hide
- A viewer scanning the app should still see what's possible
in the product, both to understand what they can ask for an
upgrade for and to avoid the UI feeling silently broken
("the New button just disappeared?"). The tooltip carries
the explanation cheaply.
Verification
- `npm run lint` → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
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>
PR 5 of the multi-user accounts series. Surfaces the account
+ role state from the profile row through useAuth so every UI
gate downstream can read it without its own fetch, and lands
two reusable patterns the upcoming Members tab / Join page /
sidebar gating PRs will all consume.
What's new in useAuth
- Profile SELECT now includes `account_id`, `account_role`, and
the joined `accounts(id, name)` via the existing `account_id`
FK. `!inner` mirrors the server-side `getCurrentAccount`
helper from PR #169.
- Context value adds `accountId`, `accountRole`, `account`
(`{id, name}` summary), and six derived booleans: `isOwner`,
`isAdmin`, `isAgent`, `isViewer`, plus the capability-level
`canManageMembers`, `canEditSettings`, `canSendMessages`.
- All derived booleans memoise off `profile.account_role`, so
consumers can use them as React dep-array entries without
re-renders cascading on unrelated profile fields.
- The no-provider fallback returns every new field at its
least-privileged value (null / false) — UI gates fail closed
if a component is somehow rendered outside the AuthProvider.
`<RequireRole min="admin" fallback={…}>{children}</RequireRole>`
- Renders `children` iff `accountRole >= min`, else `fallback`.
- Treats `profileLoading` as "below threshold" so under-
privileged users never see a flash of gated content during
the initial profile fetch.
`useCan(action)` hook
- Typed action union: `manage-members` | `edit-settings` |
`send-messages` | `view-only` | `delete-account` |
`transfer-ownership`. Closed set lets the compiler catch
typos at every call site.
- Returns `false` while loading. The boolean alternative to
`<RequireRole>` — useful for `disabled={!useCan('edit-settings')}`
on buttons / inputs / tooltip copy without restructuring JSX.
Why no React-renderer tests
- The wacrm test convention is pure unit tests (`vitest` without
any React testing library). The role predicates that back
every new piece here are already covered by the test suite
added in PR #169 — the new code is thin glue, and adding a
React testing harness for it would be a much larger change
than this PR warrants.
Verification
- `npm run lint` → 0 errors. Warnings drop from 20 → 19
because the rewritten useEffect now
lists `fetchProfile` in its deps.
- `npm run typecheck` → clean
- `npm test` → 366/366 pass
- `npm run build` → succeeds
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings Tremor (Apache-2.0) in as the project's chart component
library and converts the response-time chart as the first
integration. Tremor is distributed copy-paste ("Tremor Raw") so
the components live under `src/components/tremor/`; the only
runtime peer dependency added is `recharts`. Two small local
adaptations to the vendored `BarChart`:
- `cx` (Tremor's `clsx`+`twMerge` helper) → our `cn` from
`@/lib/utils`, aliased locally to keep diff vs upstream minimal.
- `@remixicon/react` legend-slider icons →
`lucide-react`'s `ChevronLeft` / `ChevronRight`, since
`lucide-react` is already a project dep.
- `ChartTooltipProps.label` widened from `string` to
`string | number | undefined` to match recharts 3.x (Tremor's
source still types it for recharts 2.x).
Why Tremor and not a wider switch (Mantine, HeroUI):
- The dashboard ships ~700 lines of hand-rolled SVG across three
chart files — a focused charting library is the highest-ROI
addition without touching the wider component surface.
- Tremor is Tailwind-native and built on the same Radix/Base UI
layer as the existing `src/components/ui/*`, so it composes
rather than displaces.
- Apache-2.0 + copy-paste distribution means no upgrade lock-in.
What this PR does
- Adds `src/components/tremor/{bar-chart.tsx,chart-colors.ts,
get-y-axis-domain.ts,use-on-window-resize.ts}` (vendored from
tremorlabs/tremor with the adaptations above).
- Installs `recharts@^3.8.1` (React 19 + Next.js 16 compatible).
- Rewrites `src/components/dashboard/response-time-chart.tsx`:
drops 130 lines of bespoke SVG math (axis scaling, bar
positioning, grid lines, tooltip rendering), reuses the same
card/header/empty-state shell.
Deferred (own follow-up PRs)
- `pipeline-donut.tsx` — has user-customisable per-stage hex
colours; Tremor's palette would override them. Needs either a
small upstream patch or a `colors` override path.
- `conversations-chart.tsx` — area-chart conversion (largest of
the three, 341 lines).
- The previous hand-rolled bar chart drew a horizontal dashed
"target N minutes" reference line. Tremor BarChart doesn't
expose Recharts primitives, so for now the target is promoted
to a header pill (`target 5m`). On-chart reference line is a
follow-up — either via a positioned overlay or an upstream
`referenceLines` prop.
Verification
- `npm run lint` → 0 errors (same 20 pre-existing warnings).
- `npm run typecheck` → clean.
- `npm test` → 319/319 pass.
- `npm run build` → succeeds, dashboard remains a static route.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A long reply quote was forcing the entire 3-panel inbox layout
wider, pushing the contact sidebar off-screen and making the page
horizontally scrollable. Two root causes (both correctly diagnosed
in the issue by @tekrexllp):
1. ReplyQuote applied `truncate` to the preview text, which sets
`white-space: nowrap`. Long previews became one impossibly-wide
line instead of wrapping.
2. The flex chain from MessageBubble outward (MessageActions →
middle-panel wrapper → page) had no `min-w-0` on its flex
children. Default `min-width: auto` means a flex child grows to
its content's natural width, so the long quote propagated all the
way up to the inbox-page flex container and pushed the contact
sidebar past the viewport.
Fix:
- ReplyQuote preview now uses `whitespace-pre-wrap break-words`
instead of `truncate`. The author label stays single-line (it's
a name, ellipsis is right for it). `overflow-hidden` on the
inner text container as belt-and-braces.
- MessageActions' .group/actions wrapper gets `min-w-0` so the
`max-w-[75%]` cap actually holds even with wide content.
- inbox/page.tsx middle-panel wrapper gets `min-w-0` so the
MessageThread can shrink below its natural content width when
the conversation list and contact sidebar are also flexing.
Audited the other 38 `truncate` usages across the codebase —
they're all on intentionally single-line content (contact names,
file paths, last-message previews in lists, headers, node
summaries) where ellipsis is the right behavior. ReplyQuote was
the only multi-line `truncate` misuse.
Reported and diagnosed by @tekrexllp in #165.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the trilogy. Three high-value gaps from PRs 2a/2b addressed:
1. Canvas users couldn't save. The Header (with Save / Activate /
Pause / Delete buttons) lived inside FlowBuilder so canvas-only
users had to toggle to List to persist any edit. Lifted into a
new components/flows/header.tsx that consumes useFlowEditor()
and is mounted ONCE in FlowEditorShell, above both views.
2. Validator issues weren't clickable from canvas. The
ValidationPanel was also FlowBuilder-internal. Lifted into
components/flows/validation-panel.tsx + mounted in the shell,
pinned sticky-bottom in either view. Clicks now go through the
editor context's new requestFlash(key) — both views subscribe:
- List view derives an "expanded with flash" set + an effect
scrolls the row into view. The flash class is rendered when
the card's key matches context.flashKey.
- Canvas view's pan-on-flash effect calls
reactFlow.setCenter() to bring the node into the viewport,
and the card renders an amber ring while flashed.
The flash auto-clears in the context after 1600ms; rapid
clicks on different issues cancel the previous timeout.
3. Canvas was unusable on phones — handles are ~10px and live
finger-drags between them aren't a practical workflow. Added
a useMatchMedia('(max-width: 767px)') guard in the shell that
forces list view and hides the toggle below the breakpoint.
The user's preferred view is preserved (just shadowed) so it
comes back when they widen.
Plus minor cleanup: the canvas's empty-state used to direct users
to switch to List view to add a node, but the floating "+" button
shipped in PR 2b made that stale — now shows the same Add-node
dropdown inline.
flow-builder.tsx shrinks by another ~370 lines (Header + StatusBadge
+ ValidationPanel + IssueLine + the local flashedKey + jumpToNode
all moved or replaced by context subscription).
No new tests — the change is structural + UI-only. Existing 319
still pass; typecheck, lint, build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the editing-affordance gap from PR 2a. The canvas is now in
full parity with the list view for everything that fits in a node
graph: connections, deletes, and adding nodes all work on-canvas.
Adds:
- Per-slot Handle components on the custom node renderer. Single-
outgoing types get one handle on the right edge; condition gets
`true` / `false` handles; send_buttons and send_list render slot
rows inside the card with one handle each, so the user can SEE
which button or list row they're wiring. Every non-start node
gets a target handle on the left.
- Drag-to-connect (`onConnect`). Looks up the source node, dispatches
the right config patch via the new `applyEdgeConnection(node,
sourceHandle, target)` helper, and routes through updateNodeConfig.
Rejects self-loops silently (a button whose target is its own node
= infinite reprompt).
- Keyboard delete on nodes (Backspace + Delete, both for cross-OS
parity). Routes through removeNode, which now ALSO unlinks every
inbound reference via the new `unlinkNodeReferences(nodes, key)`
helper. No more dangling arrows after a delete — the validator
previously flagged these as errors and the user had to chase them
down manually.
- Edge delete. Selecting an edge + Delete clears just that slot's
next_node_key (rather than removing anything), via the same
applyEdgeConnection helper passing "" as the target.
- Floating "+ Add node" button (React-Flow Panel, bottom-right).
Same dropdown as the list view; drops the new node at the visible
viewport center via reactFlow.screenToFlowPosition.
New pure helpers in src/lib/flows/edges.ts (no React, fully unit-
tested — 18 new tests, 319 total passing):
- outgoingSlots(node) — list of {id, label} per outgoing slot.
- applyEdgeConnection(node, sourceHandle, targetKey) — config patch
for a drag-to-connect, or null for unrecognised handles.
- unlinkNodeReferences(nodes, deletedKey) — clears every direct +
nested reference to deletedKey. Returns nodes-by-identity when
no reference matches to avoid needless re-renders.
The hook's removeNode is the only consumer of unlinkNodeReferences
right now, which means LIST-view deletes also stop leaving dangling
refs — strict improvement for both views.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Builds on PR 1's read-only canvas. This PR brings the canvas to ~75%
of list-view parity: drag-to-reposition (persisted) and click-to-edit
via a side panel that mounts the same per-node forms the list view
uses. Drag-to-connect + add/delete from canvas land in PR 2b.
Architecture (the bulk of the diff):
- Extracted the per-node forms (SendButtonsForm, SendListForm,
SendMediaForm, ConditionForm, SetTagForm + inline forms for the
smaller types) plus the shared field components (TextRow,
NextNodeRow, NodeKeySelect) from flow-builder.tsx into
src/components/flows/forms/. Both views now mount the SAME
NodeConfigForm dispatcher so behaviour can't drift between
"what the list editor shows" and "what the canvas side panel
shows".
- Lifted all editor state into a context/hook
(src/components/flows/flow-editor-state.tsx) with a
FlowEditorProvider that wraps both views in flow-editor-shell.
Single source of truth: toggling Canvas ⇄ List never resets
unsaved edits, and a drag on the canvas updates the same nodes
array the list view reads.
- flow-builder.tsx shrinks from ~2400 lines to ~900 (extracted
forms) and stops owning state (now consumes via useFlowEditor()).
List-only UI state — expanded cards, scroll refs, jump-to-node
flash — stays internal.
Canvas additions:
- onNodeDragStop writes the new position to context via
updateNodePosition (flips dirty; serialized in the existing PUT
/api/flows/[id] body — the route already accepts position_x /
position_y per migration 010).
- onNodeClick opens a controlled shadcn Sheet (right-side panel)
with the per-node form, plus "Set as entry" and "Delete node"
buttons. Esc / overlay / close button all close.
What's intentionally still NOT in this PR (PR 2b):
- Per-slot Handle components on the custom node renderer.
- Drag-to-connect handler that maps target → updated
next_node_key for the right slot.
- Floating "+" button to add nodes from canvas.
- Keyboard / context-menu delete.
Tests: 9 new pure-helper tests (uniqueNodeKey + defaultConfigFor
self-consistency). Hook integration tests are deferred — would
require @testing-library/react + jsdom infra. Existing 271 tests
still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The second half of the user feedback that drove send_media: the
linear-list editor reads as a checklist, not a branching flow, which
made conversational structures hard to understand at a glance. This
introduces a canvas view alongside the list — boxes + arrows, drag-
zoom-pan, labeled edges per branch slot. Canvas is the new default;
the list stays as a fallback toggle (per-browser preference,
persisted to localStorage).
PR 1 scope is intentionally read-only: render, don't edit. The
foundation is testable in isolation before adding drag-to-persist,
side-panel editing, and drag-to-connect (PR 2) and polish like
keyboard shortcuts, mobile fallback, and validator integration
(PR 3).
Pieces:
- New @xyflow/react (~30 KB gz) + @dagrejs/dagre (auto-layout for
pre-existing flows whose position_x / position_y are all zero —
flow_nodes already reserved those columns in migration 010
precisely for this).
- Shared module src/components/flows/shared.tsx lifts NodeType,
BuilderNode, NODE_META, summarizeNode, truncate so both views
render nodes identically.
- Pure helpers (no React, fully unit-tested):
src/lib/flows/edges.ts — derive labeled canvas edges per
outgoing slot (button, list row,
condition branch)
src/lib/flows/layout.ts — dagre wrapper + shouldAutoLayout
guard so manually-positioned flows
never get rearranged on open
- FlowCanvas — client component, custom node renderer matching the
list view's collapsed-card styling, MiniMap + Controls, attribution
hidden.
- FlowEditorShell — owns the Canvas / List toggle, conditionally
mounts the right view.
21 new unit tests (layout + edges); existing 271 still pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets flows attach an image, video, or document (e.g. PDF invoice /
receipt) to a customer message — the most-requested gap in v1 was that
nodes were text-only. Single node with a `media_type` discriminator
rather than three node types: Meta's wire payload differs only in the
top-level key, so three types would triple the surface area (forms,
engine cases, menu entries) for no behavioural difference.
Files are uploaded directly from the builder to a new public
`flow-media` Supabase Storage bucket (per-user RLS, 16 MB cap, MIME
allowlist) and the resulting public URL is what Meta fetches at send
time. Same trade-off as the existing avatars bucket.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the silent-failure bug a user reported: when multiple phone
numbers are connected to the same WABA, only the first one actually
receives webhook events from Meta. The wacrm UI shows "Connected"
because the metadata probe succeeds, but Meta's activity log is
empty for every subsequent number.
Root cause
----------
The POST /api/whatsapp/config handler verified credentials with a
GET /{phone_number_id} probe and saved them to Supabase — but
never called Meta's two registration endpoints that actually wire
a number for inbound events:
1. POST /{phone_number_id}/register
Subscribes the number to THIS app's webhook. Requires a
6-digit 2FA PIN the user previously set in Meta WhatsApp
Manager → Two-step verification. Without it, Meta routes
events for that number to whichever app last claimed it.
2. POST /{waba_id}/subscribed_apps
Subscribes the WABA itself to this app. Required once per
WABA, but idempotent on Meta's side so we call on every
save without harm.
The first number "worked" because someone (the user, Embedded
Signup, or Meta dashboard) had already done both calls for it
out-of-band. Every subsequent number silently failed.
What this PR does
-----------------
* New helpers in meta-api.ts: registerPhoneNumber (treats Meta's
"already registered" 400 as success), subscribeWabaToApp
(idempotent), getSubscribedApps (for the diagnostic).
* POST /api/whatsapp/config now:
- Accepts an optional `pin` (6-digit, validated).
- Calls subscribeWabaToApp on every save (idempotent).
- Calls registerPhoneNumber on first save and whenever a PIN
is supplied. Skipped when the same number is already
registered locally and no fresh PIN is provided — re-running
/register with a stale PIN would actually undo the active
subscription.
- Persists registered_at / subscribed_apps_at timestamps and
last_registration_error for diagnostic surfacing.
- Returns {success, registered, registration_error} so the UI
can distinguish "creds valid" from "actually live".
- Saves the row even when /register fails so the user can
retry without re-entering everything.
* New diagnostic GET /api/whatsapp/config/verify-registration —
hits Meta to confirm the saved number is reachable + the WABA
is subscribed. Returns a per-check breakdown the UI renders.
Solves the misleading-green-banner UX failure that caused the
bug to go unnoticed in the first place.
* Migration 015 adds registered_at, subscribed_apps_at,
last_registration_error to whatsapp_config. All nullable so
existing rows survive — the UI labels them "registration status
unknown" until the user clicks Verify or re-saves.
* whatsapp-config.tsx:
- New PIN input with help text pointing to the exact Meta
Manager setting and explaining why it's needed.
- Two-tier status: existing "Credentials valid" alert (relabeled
from "Connected" since it was misleadingly broad) PLUS a new
Registration Status alert that flips emerald/amber based on
the registered_at timestamp.
- "Verify with Meta" button on the registration alert pings
the diagnostic endpoint and renders the per-check result
inline. Surfaces last_registration_error verbatim with a
pointer to retry.
- PIN clears from state after a successful save so it can't
accidentally retrigger /register on the next save.
Tests + checks
--------------
* 9 new fetch-mock tests cover registerPhoneNumber (happy path,
already-registered passthrough, PIN-required error verbatim,
generic Meta error), subscribeWabaToApp (URL + bearer + error),
getSubscribedApps (data, empty, error).
* 265/265 tests pass. Lint 0 errors. Typecheck clean. Build
registers both /api/whatsapp/config and
/api/whatsapp/config/verify-registration.
Out of scope
------------
* Multi-number-per-CRM-user (current schema is UNIQUE(user_id)
so one CRM user = one number). The reported bug was
multi-number-per-WABA across multiple CRM users, which this
fully resolves.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A focused cleanup PR that addresses the high-signal findings from
a self-review of PRs #148-153. Every change is local to the
templates surface; no schema or wire-format changes.
Safety
------
* Delete now goes through a confirmation Dialog (mirrors the
existing tag-manager pattern). Copy explicitly tells the user
when Meta will be hit, so a slip on the trash icon doesn't take
the template off Meta as well as locally.
* AUTHENTICATION submit is now blocked by a persistent in-dialog
banner + disabled submit button. The disappearing toast that
used to fire on submit is gone — defensive `return` remains in
handleSubmit as a second line of defense.
* updateButton uses a per-variant switch + ButtonPatch union
instead of the partial-spread + `as TemplateButton` cast. Each
variant's spread keeps its discriminant pinned; TS enforces
exhaustiveness.
* New template-row-guard.ts surfaces malformed Supabase rows at
the route boundary instead of letting buildSendComponents crash
deep in the stack with an opaque TypeError.
* UUID regex check on /[id] PATCH+DELETE so non-UUID payloads
return a clean 400 before hitting Supabase.
Submit-route cleanup
--------------------
* Two near-identical upsert blocks (error path + success path)
collapsed into a buildUpsertRow + upsertTemplateRow helper.
Adding a column now touches one place, not two.
UX copy + feedback
------------------
* Submit success toast sets the right expectation: "typical
review time is 24 hours. Status updates automatically." Old
copy just said "status will update once approved."
* Header media URL helper text is now actually useful: HTTPS
requirement, ~24h lifetime, per-format size/format recommendations
(JPEG/PNG ≥800×418 ≤5 MB for images, MP4 ≤16 MB for video, etc).
* Truncated-sync toast switches from `warning` (which auto-dismisses
fast) to `error` with `duration: 10000` and concrete copy: "Synced
the first 2000 templates only — your account has more. Sync again
to continue."
* Edit and Resubmit buttons gain visible text labels alongside
their icons + better aria-label/title pairs. A lone rotate icon
wasn't self-explanatory.
* Broadcast picker drops the redundant "Approved" chip — every
template in that list is APPROVED by filter, so the chip carried
zero information. Unused templateStatusConfig import removed.
State + a11y
------------
* handleSubmit now awaits fetchTemplates BEFORE closing the dialog
so re-opening immediately doesn't see a stale list.
* Header content / media URL / sample are preserved across format
switches in the New Template form. The payload builder is already
format-aware, so orphan values on hidden fields are harmless —
and keeping them lets users switch formats to compare without
losing typing.
* Button-type Select gains the same null-guard the header Select
got in PR #148 (per @base-ui semantics, onValueChange can fire
with null on deselect).
* Dynamic body-sample inputs get id + aria-label so each one is
individually addressable (WCAG A — screen-reader announcement
beat "edit text"). Header text + header sample inputs too.
Deliberately NOT here
---------------------
* The 940-line template-manager component is not split. It's
cohesive (one feature surface) and splitting for size alone
would be over-engineering.
* extractVariableIndices in the buttons loop is not memoized.
Regex on a short URL string is sub-microsecond; a real profiler
would never flag it.
* No JS heap secret-clearing for access tokens. Strings are
immutable in JS — there's no way to actually clear one.
Tests: 256/256 pass (no new tests required — pure refactor +
copy + new UI elements). Lint 0 errors. Typecheck clean. Build
succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the last hole left by PR 3 — until now, sendTemplateMessage
only emitted a body component, so templates with image/video/
document headers or URL-with-{{1}} buttons failed at send time
with Meta's "missing component" 400. After this PR, every
APPROVED template that wacrm can submit is also fully sendable.
New helper
----------
src/lib/whatsapp/template-send-builder.ts builds the per-send
Meta components array from a MessageTemplate row + caller-supplied
SendTimeParams. It's a different beast from template-components.ts
(which builds the SUBMISSION shape) because the send-time payload
has a different schema: header type-specific parameter objects,
button components with sub_type + index, body_text without a 2D
example wrapper, etc.
Auto-fills as much as possible from the template:
* static IMAGE/VIDEO/DOCUMENT headers ride along using the row's
header_media_url (or header_handle when present — preferred)
* URL buttons without {{1}} need no per-send payload
* QUICK_REPLY / PHONE_NUMBER buttons need nothing
* COPY_CODE buttons fall back to the template's example so the
most common "static promo code" case Just Works without UI
Variable-bearing fields require per-send values:
* TEXT header with {{1}} → messageParams.headerText
* Body variables → messageParams.body[]
* URL button with {{1}} → messageParams.buttonParams[i]
* COPY_CODE override → messageParams.buttonParams[i]
19 unit tests assert the wire shape per scenario — header types,
URL button at correct index when QR buttons precede it, example
fallback, and the throw-with-specific-message paths.
sendTemplateMessage extension
-----------------------------
src/lib/whatsapp/meta-api.ts gains optional `template` +
`messageParams` args. When `template` is provided, the full
components array is built via the new helper. Legacy `params:
string[]` (body only) path is preserved as-is — old callers
behave identically. When both shapes are present, structured
messageParams.body wins.
Route + UI wiring
-----------------
POST /api/whatsapp/send and /api/whatsapp/broadcast now load the
template row by (user_id, name, language) and pass it through.
Broadcast loads once per request (not per recipient — avoids N+1).
Both accept `template_message_params` / per-recipient
`messageParams` from the client.
src/components/inbox/template-picker.tsx scans the selected
template for variable-bearing fields and renders an input per
need: header text variable, body variables (existing), URL
button suffixes. Per-button preview shows the final URL with
the substitution so the user sees what the recipient will see.
The callback shape changes to `(template, { body, headerText?,
buttonParams? })`; message-thread.tsx updated accordingly.
Out of scope
------------
Broadcast composer doesn't yet collect per-recipient header /
button params — the legacy body-only flow continues to work for
broadcasts. Templates with header / URL-button variables broadcast
fine if every recipient gets the same value via API call shape;
the UI for varying it per recipient lands in a follow-up.
This is the last PR of the Issue #147 overhaul sequence.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the missing lifecycle operations so users can manage existing
templates without leaving wacrm.
API surface
-----------
PATCH /api/whatsapp/templates/[id]
Edits (and re-submits) an existing Meta-side template. Used by
the "Edit" action on APPROVED rows and "Resubmit" on REJECTED /
PAUSED rows — same operation, different UI labels. Meta replaces
components wholesale and flips status back to PENDING for
re-review. Status guard rejects edits on PENDING / DISABLED /
IN_APPEAL / PENDING_DELETION and on local-only (no
meta_template_id) rows. Honors WHATSAPP_TEMPLATES_DRY_RUN.
DELETE /api/whatsapp/templates/[id]
Calls Meta `DELETE /{waba_id}/message_templates?name=X&hsm_id=Y`
when meta_template_id is set — hsm_id is critical, without it
Meta deletes EVERY language variant of the same name. Treats
Meta 404 as a no-op (template already gone). Always removes the
local row.
New Meta helpers
----------------
src/lib/whatsapp/meta-api.ts gains editMessageTemplate and
deleteMessageTemplate following the established named-args +
throwMetaError pattern. Eleven new fetch-mock tests assert the
exact wire shape — especially that hsm_id always rides along when
provided, which is the difference between "delete one variant"
and "delete every variant."
UI changes
----------
src/components/settings/template-manager.tsx:
* Edit (pencil) button visible for APPROVED rows
* Resubmit (rotate) button visible for REJECTED / PAUSED rows
— same flow as Edit, different label
* Delete now calls the route handler (so Meta gets the call too)
with a spinner during the in-flight request
* Dialog pre-fills form from the row when opened in edit mode
* Dialog title / CTA / description switch between "New Message
Template" + "Submit for Approval" and "Edit Message Template"
+ "Save & Resubmit"
* Name + language inputs are disabled in edit mode (Meta keys
templates by id; changing either is effectively a new template)
* Closing the dialog clears editingId so the next open starts
fresh
Design decision
---------------
One PATCH endpoint instead of the two endpoints the plan called
for (PATCH for edit, POST for resubmit). The Meta call is the same
operation either way — the difference is UI semantics, handled in
the component. Cuts duplication; easy to split later if needed.
Out of scope (final PR remaining in the sequence):
* Webhook handler for real-time status (PR 5)
* sendTemplateMessage media + button param support (PR 6)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the second half of #147. Users can now create a template in
wacrm and submit it to Meta for approval without leaving the app —
no more dummy local rows that fail with Meta error #132001 on send.
API surface
-----------
POST /api/whatsapp/templates/submit
Auth → fetch whatsapp_config → validate payload → build Meta
components → POST to /{waba_id}/message_templates → persist row
with meta_template_id + status + sample_values +
last_submitted_at. Per-field validators throw with specific
messages before the network call, so misconfigured templates
fail with "button #3 missing url" instead of a 400 from Meta.
429s are detected and surfaced as "rate limit hit (100/hr)".
WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and
issues a synthetic `dry-run-<uuid>` meta_template_id so CI and
local dev can exercise the full form without a real WABA.
New modules
-----------
src/lib/whatsapp/template-validators.ts
Pure functions encoding Meta's caps: 1024-char body, 60-char
footer/header, 25-char button text, 10 buttons max, 2 URL / 1
PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL
example required when {{1}} is present, COPY_CODE example
required, header media URL must be http(s), variables must be
contiguous starting at {{1}}. 41 unit tests cover each rule.
src/lib/whatsapp/template-components.ts
Translates our local row shape into Meta's components array
(HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D
array per spec, header_handle preferred over header_url when
both are present). Snapshot tests assert the wire shape.
src/lib/whatsapp/template-status-normalize.ts
Single source of truth for upstream→local status mapping.
Replaces the duplicated normalizer in sync/route.ts; the
upcoming webhook handler will share it too.
src/lib/whatsapp/meta-api.ts
New submitMessageTemplate() helper following the existing
named-args + throwMetaError pattern.
Form rewrite
------------
src/components/settings/template-manager.tsx replaces the flat
form with a component-based builder:
* Header: format select (None / Text / Image / Video / Document)
+ conditional inputs — text + variable-aware sample row for
TEXT, URL paste for media (file upload slot ready for the
Resumable Upload follow-up)
* Body: textarea with live {{N}} extraction → sample-value rows
auto-appear and shrink as variables are added/removed
* Footer: 60-char capped input
* Buttons editor: add up to 10, per-row type picker
(Quick Reply / URL / Phone / Copy Code), conditional URL +
example + phone + code fields, X to remove
* Card display now surfaces rejection_reason / submission_error
(red banner) and quality_score (GREEN/YELLOW/RED chip)
* AUTHENTICATION shows a "create in Meta Manager + Sync" banner
and disables Submit — that category has a different shape
(fixed body, mandatory OTP button) and lands in a follow-up
Side effects
------------
* Broadcast Step 1 picker (step1-choose-template.tsx) now
filters to status='APPROVED' to match the inbox picker —
selecting a non-approved template would 400 on send.
* .env.local.example documents META_APP_ID (Resumable Upload
follow-up) and WHATSAPP_TEMPLATES_DRY_RUN.
Out of scope (subsequent PRs):
* Edit / resubmit / delete endpoints (PR 4)
* Webhook handler for real-time status (PR 5)
* sendTemplateMessage media + button param support (PR 6)
* Resumable Upload + AUTHENTICATION builder (follow-up)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches message_templates.status from the local TitleCase set
(Draft|Pending|Approved|Rejected) to Meta's raw enum (DRAFT|PENDING|
APPROVED|REJECTED|PAUSED|DISABLED|IN_APPEAL|PENDING_DELETION) and
adds the columns the upcoming submit / edit / resubmit / webhook
flows need. No new user-facing behavior — this is groundwork.
Migration 014 also adds:
* sample_values JSONB Meta requires these for variable templates
* meta_template_id TEXT hsm_id for scoped edit/delete (avoids
nuking sibling-language variants)
* rejection_reason TEXT surfaced from webhook on REJECTED
* quality_score TEXT GREEN | YELLOW | RED, from webhook
* header_handle TEXT from Resumable Upload (media headers)
* header_media_url TEXT URL fallback for media headers (v1 path)
* submission_error TEXT last 4xx from Meta, for retry surface
* last_submitted_at rate-limit awareness (100 creates/hour)
* buttons JSONB shape CHECK permissive array-of-typed-objects guard
(strict per-type validation lives in API)
* UNIQUE (user_id, name, language)
so sync upsert is atomic and prevents
duplicate language variants
* idx on meta_template_id fast webhook lookup
Sync route now:
* persists Meta's status verbatim (PAUSED no longer collapses to
REJECTED — these are different states with different recoverability)
* parses BUTTONS components into the new discriminated-union shape
* extracts sample_values from body.example.body_text + header.example
* persists meta_template_id, quality_score, header_handle
Shared template-status.ts gives the manager / inbox picker / broadcast
picker one source of truth for labels + dark-theme badge classes.
Part of the Issue #147 overhaul plan (PR 2 of 6 in the sequence).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The header type Select uses value="none" as a sentinel (Radix disallows
value=""), but the Save handler passed it through verbatim to Supabase.
The DB CHECK constraint on message_templates.header_type only allows
text|image|video|document, so picking "None" raised a constraint
violation and the template never saved.
Normalize "none" (and any falsy value) to '' at the input boundary so
form.header_type is always either '' (→ null on save) or a valid type.
Closes#147 (the header-dropdown half — Meta submission is tracked separately).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Flows feature was previously gated on a per-account
`profiles.beta_features` opt-in: the sidebar entry was invisible
to non-beta accounts, every /api/flows route 404'd, the webhook
dispatcher was a no-op, and the agent-handoff pause was skipped.
This PR opens the surface to every authenticated user. A small
"Beta" chip in the sidebar nav + on the /flows page H1 keeps the
"young feature" signal without holding anyone out.
Removed:
- `src/lib/flows/feature-flag.ts` + its tests (-11 tests).
- The `requireFlowsBeta` / `requireFlowsBetaAndOwnership` helpers
in the API routes — collapsed to plain `requireUser` / RLS
ownership.
- The `isProfileFlowsEnabled` helper + branch in the webhook
dispatcher — the runner takes the cheap "no_match" exit on
accounts with no active flows (single indexed SELECT).
- The agent-handoff pause gate in /api/whatsapp/send — the UPDATE
matches zero rows when no active run exists, also cheap.
- Client-side beta gates in the 3 flows pages (and the redundant
`useAuth` + `profileLoading` plumbing they needed).
- The defensive message-bubble gate that would have rendered
interactive replies as plain text for non-beta accounts.
- The `betaKey` field on the sidebar NavItem schema, replaced by a
simpler boolean `beta` that purely drives the visual chip.
Kept:
- `profiles.beta_features` column. Generic mechanism for future
beta gates; no current feature reads it. Comments in
`src/types/index.ts` + `use-auth.tsx` updated to say so.
- The chip can be removed in a one-liner once the team's satisfied
Flows has bedded in.
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>
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>
"No issues. Ready to activate." (and the equivalent error count)
sat at the very bottom of the editor. With even a handful of nodes
the panel was below the fold — the user had to scroll past every
node card to see whether they could activate.
Wrap the panel in a sticky-bottom container so the activate-readiness
status follows the user as they scroll. Shadow-xl gives it the lift
to read as "pinned" against the node list behind it.
Side fix while I was in there: the success-state panel used
bg-emerald-500/10 (10% emerald on whatever's behind). Fine when
nothing's behind, but sticky-positioned over a slate-900 node card
the green tint bled through ugly. Switched both states to a
bg-slate-950 base with the severity expressed in the border + text
color only — readable on any backdrop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Editing 5 node configs and then refreshing the tab silently wiped them.
Activate also silently invoked Save first — useful, but invisible: users
had no way to know if pending edits had landed.
Adds:
- A `dirty` boolean flipped true on any user edit, false on a successful
PUT. Internal `state.status` changes from the activate API path use
raw setState and don't dirty the form.
- An "● Edited" amber chip next to the StatusBadge while dirty.
- A `beforeunload` listener that prompts the native confirm on
reload / tab-close / external-link click when dirty.
SPA navigation (sidebar, back button) isn't covered — Next 16's App
Router doesn't fire beforeunload on client-side routes. Worth a
follow-up but the accidental-refresh case is the noisier one.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The validation panel sits at the bottom of the editor. When it surfaced
an error ("node X is missing next_node_key") the user had to scroll up,
find that node, expand it, find the broken field. Long feedback loop.
Each node-scoped IssueLine becomes a button: click it and the editor
expands the offending node, scrolls it into view, and briefly rings
its border so the eye lands on the right card. Trigger-scoped issues
stay non-interactive (the trigger panel is already at the top of the
page).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A flow with 8 send_message nodes used to show 8 identical-looking pills
in the editor — the only way to tell them apart was to expand each
one. Adds a one-line summary below the meta label when the card is
collapsed:
- send_message → first 80 chars of text
- send_buttons → text + button titles
- send_list → text + option count
- collect_input → prompt + target var
- condition → "var.email == 'support@…'"
- set_tag → "Add tag 1f8c…"
- handoff → first 80 chars of note
Empty nodes (just added, no fields set) keep the old compact look so
you can tell at a glance what still needs configuration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The data model and the runner already supported multiple sections in a
WhatsApp interactive list, but the builder UI exposed no way to add one
— it only let you add rows within the single default section. So users
couldn't build a categorized menu (Billing / Support / Sales) without
editing the DB directly.
Adds an "Add section" button below the sections list (capped at the
WhatsApp spec's 10) and a per-section remove button (shown when more
than one section exists).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Node key and per-button/row reply IDs are auto-generated and consumed
only by the runner — exposing them in the main form read as developer
leakage. Move them behind a per-node "Show advanced" toggle so the
default UI shows only customer-facing fields. Power users who need
stable IDs for analytics can still get them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Final feature PR before GA cleanup. Closes the gap between "flow
runner works" (PR 2) and "users have everything they need to build
real automation" — three new node types, plain-text sends actually
sending, three starter templates, a run-history page for debugging.
What landed:
1. engineSendText helper (src/lib/flows/meta-send.ts) — wraps
sendTextMessage with the phone-variant retry + DB persistence
pattern. Unblocks send_message + collect_input nodes that
previously auto-advanced without sending.
2. Three new node types in the engine:
- collect_input: sends a prompt, suspends, captures the next
text reply into flow_runs.vars[var_key], advances. Supports
{{vars.X}} interpolation in downstream prompts + handoff
notes.
- condition: branches on subject (var / tag / contact_field)
with operators (equals / contains / present / absent).
Auto-advancing, no Meta call. Pure predicate logic exported
as evaluateConditionPredicate with 7 new unit tests.
- set_tag: upsert/delete on contact_tags then advance. Non-
fatal on write failure — logged + advances anyway so a tag
glitch doesn't strand the customer.
3. Real send_message — calls engineSendText, logs the result,
handles errors by ending the run with status='failed'. Same
{{vars.X}} interpolation.
4. Three starter templates (src/lib/flows/templates.ts):
- Welcome menu (the owner's brief example)
- FAQ bot (keyword → list → answers → end)
- Lead capture (first_inbound → collect_input chain → handoff
with vars in the note)
POST /api/flows now accepts {template_slug} to clone a
template into a fresh draft. GET /api/flows/templates exposes
the gallery shape (no nodes payload — fetched on actual clone).
5. Builder UI extensions:
- NODE_META + config sub-forms for collect_input, condition,
set_tag.
- Template gallery on the /flows New-flow dialog (loads from
/api/flows/templates, falls back gracefully if absent).
- "Runs" button in the editor header → /flows/[id]/runs.
6. Run-history viewer at /flows/[id]/runs — newest-first list of
the 50 most recent runs, with expandable per-run event
timeline (started, node_entered, message_sent,
reply_received, fallback_fired, handoff, timeout, error,
completed). Useful for debugging "why didn't my flow advance?"
from the brief's use case.
7. Validator updates: new error rules for each new node type
(prompt_text required, var_key alphanumeric, operator valid,
true_next/false_next exist, etc.). outgoingEdges helper now
covers collect_input/condition/set_tag for reachability.
All gated behind isFlowsEnabled(profile). Non-beta accounts see no
change. Run-history API is also 404 for non-beta.
Validation: 173/173 tests pass (added 7 new for the predicate);
typecheck + lint + build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Ships the surface that lets opted-in beta accounts build, save, and
activate WhatsApp conversation flows entirely from the web UI — no
SQL fixtures needed. Sidebar gets a "Flows" entry (hidden for
non-beta accounts), the /flows list page surfaces existing flows,
/flows/[id] is the linear-list editor.
Wraps the runner from PR 2 with everything a real user needs to
build the welcome-menu example end-to-end.
What landed:
1. `src/lib/flows/validate.ts` — pure validator. Checks entry
node exists, all next_node_key refs resolve, no unreachable
nodes, Meta-limit violations (button title ≤20, ≤3 buttons,
≤10 list rows, ≤24 chars per list row). 24 unit tests.
2. `src/app/api/flows/route.ts` — GET (list user's flows),
POST (create draft).
3. `src/app/api/flows/[id]/route.ts` — GET (load with nodes),
PUT (replace name + trigger + nodes; delete-then-insert),
DELETE (cascade clean-up).
4. `src/app/api/flows/[id]/activate/route.ts` — status toggle.
Activation runs the validator; returns 422 + the full issue
list when blocked.
5. `src/app/(dashboard)/flows/page.tsx` — list of cards, "New
flow" dialog, delete with confirm.
6. `src/app/(dashboard)/flows/[id]/page.tsx` — editor shell
that loads data and hands it to FlowBuilder.
7. `src/components/flows/flow-builder.tsx` — the editor itself.
Header with name + save/activate/pause/delete; trigger
panel; entry-node picker; node list with expandable per-node
config forms (start, send_buttons, send_list, send_message,
handoff, end); bottom validation panel that mirrors what the
server's activate endpoint would reject.
8. `src/components/layout/sidebar.tsx` — Flows entry between
Automations and Settings, rendered only when isFlowsEnabled.
Every API route + page is gated server-side by isFlowsEnabled —
non-beta accounts get 404 (not 403) so the routes don't even hint
at existing. UI gate is for snappy redirect; security is at the
API layer.
Validation: 166/166 tests pass (added 24 validator tests);
typecheck + lint + build all clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the new `case 'interactive'` rendering with `isFlowsEnabled(profile)`
from the beta-features module that landed in #113. Non-beta accounts
fall back to the plain-text rendering for `content_type='interactive'`
rows — they get the option title as text, no special "↩ Button reply"
affordance.
Why this gate exists even though the case is unreachable today:
- No code path on this branch produces `content_type='interactive'`
rows. The flow runner that does is upcoming (PR #2), and it'll be
behind the same gate.
- But: if a stray interactive row ever shows up in a non-beta
account (manual SQL insert, data import, future bug), we'd rather
the account see boring plain text than UI for a feature they
haven't opted into.
- The cost is ~3 LoC and one hook call. Pure paranoia insurance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Foundation for the upcoming WhatsApp chatbot flow builder. No runner,
no UI yet — just the plumbing that lets the codebase SEND interactive
messages (button replies + lists) and RECEIVE the taps as structured
message rows. Plan: ~/.claude/plans/understood-research-such-functionality-binary-lake.md
## What's in this PR
### Migration 010_flows.sql
- New tables: flows, flow_nodes, flow_runs, flow_run_events
- Linchpin partial unique index on flow_runs(user_id, contact_id) WHERE status='active' — at most one active run per contact, makes concurrent webhook deliveries idempotent at the DB layer
- Widens messages.content_type CHECK to allow 'interactive'
- New messages.interactive_reply_id column for the tapped button/row id
- RLS on every table; flow_runs added to supabase_realtime publication
- Idempotent throughout — matches the 009_message_actions.sql template
### Interactive Meta senders (src/lib/whatsapp/meta-api.ts)
- sendInteractiveButtons: type='interactive', interactive.type='button', up to 3 reply buttons
- sendInteractiveList: type='interactive', interactive.type='list', up to 10 rows across sections
- Limits exported as INTERACTIVE_LIMITS so flow validation can share the constants
- All Meta caps validated BEFORE the network call (button title ≤20 chars, list row title ≤24 chars, ≤10 total rows, no duplicate row ids, body ≤1024 chars, etc.) — misconfigured flows fail at save time instead of as a customer-facing 400 mid-conversation
### Webhook (src/app/api/whatsapp/webhook/route.ts)
- Extends WhatsAppMessage interface with `interactive?: { type, button_reply?, list_reply? }`
- parseMessageContent returns a new `interactiveReplyId` field; refactored to use a shared `empty` base so every return site picks up the new field without 9 explicit copies
- New `case 'interactive'`: uses the tapped option's TITLE as content_text (so the inbox bubble reads "Existing customer", not a UUID) and stashes the id separately
- 'interactive' added to ALLOWED_CONTENT_TYPES so the messages insert no longer coerces it to 'text'
### Inbox bubble (src/components/inbox/message-bubble.tsx)
- New `case "interactive"` in MessageContent renders the tap with a small "↩ Button reply" affordance so agents reading the inbox can tell at a glance that this was a tap rather than the customer typing the same words
### Types (src/types/index.ts)
- ContentType union extended with 'interactive'
- Message.interactive_reply_id field
### Engine helpers (src/lib/flows/)
- admin-client.ts: thin service-role client wrapper, mirrors automations/admin-client.ts
- meta-send.ts: engineSendInteractiveButtons / engineSendInteractiveList — adapts the Meta senders for service-role use, persists messages with sender_type='bot' content_type='interactive', uses the same phone-variant retry as automations/meta-send.ts. Unused in PR #1; ready for the runner in PR #2
### Tests
- 13 new vitest cases in meta-api.test.ts covering Meta's limit validation + payload-shape assertions for both interactive senders
## Validation
- npm test → 102/102 (was 89; 13 new)
- npm run lint → 0 errors
- npm run typecheck → clean
- npm run build → clean
## What's NOT in this PR
- The runner (dispatchInboundToFlows) — PR #2
- Builder UI / API surface — PR #3
- collect_input / condition / set_tag nodes + templates — PR #4
- react-flow canvas + http_fetch — v2 (PR #5)
## Risks tracked from the plan
- messages.content_type CHECK widening: grep'd every consumer; message-bubble.tsx, reply-quote.tsx, send/route.ts, automations/meta-send.ts all handle the new variant correctly (or accept it as a defaulted fallthrough)
- engineSendText / engineSendInteractive share ~80% of code; deliberately not pre-factored yet — let both stabilize, extract a shared base in PR #2 once the shape settles
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a subtle, repeating SVG pattern behind the message thread and the
"select a conversation" empty state — same visual language WhatsApp uses
in their own chat UI, themed for a CRM (message bubble, phone, mail,
calendar, lightbulb, briefcase, trophy, dollar sign, percent, etc).
Asset: public/inbox-doodle.svg, 6 KB, 240×240 tile that repeats
seamlessly. Icon shapes are taken verbatim from lucide-react (already
a dependency, every other icon in the UI uses it) so the doodle reads
as part of the same visual system, not a separate clip-art import.
Stroke is slate-500 at 22% opacity over bg-slate-950 — visible but
not competing with message text.
Wiring: a single DOODLE_BG_CLASSES constant in message-thread.tsx
applied to both the empty-state and active-thread containers, so they
can't drift. The header keeps bg-slate-900 (solid) and the composer
keeps its own background, so the doodle only shows in the actual
message scroll area — exactly like WhatsApp.
No JS, no perf hit beyond a one-off 6 KB asset fetch that's then
browser-cached forever (it's served from /public with Next's default
caching).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A small icon button (lucide RefreshCw) in the message-thread header,
sitting just left of the Status dropdown. Clicking it bumps the
parent's `resyncToken` — the same channel that already drives the
WS-reconnect and tab-visibility-resume refetches. So the button goes
through the existing dedupe + fetch plumbing in ConversationList and
MessageThread; no parallel code path to keep in sync.
The icon spins for 700ms as click feedback (the refetch itself is
fire-and-forget — the children just refire their fetch effects). The
button is gated behind the optional `onRefresh` prop, so existing
callers of MessageThread (and tests, if any are added later) keep
working unchanged.
Useful when the agent suspects realtime missed something — or just
wants to be sure the list and thread reflect the latest DB state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses #106 (issue stays open until the reporter confirms in prod).
#105's `let foundInList = false; setState(p => { foundInList = ...; ... })`
pattern is broken: setState updaters run during reconciliation, *after*
the synchronous handler returns, so `foundInList` reads `false` in the
same tick and `hydrateConversation()` fires on every code path the
flag was meant to gate. Every incoming message and every conversation
UPDATE (read receipts, status flips, agent assignment) triggered a
redundant DB fetch, queuing HTTP behind realtime WS frames on the same
supabase client and starving the channel — which surfaced as exactly
what #106 reports: messages that never arrive, threads stuck on "No
messages yet", events landing out of order.
The fix:
1. Synchronous known-IDs ref. `knownConvIdsRef` mirrors the conv ids
currently in state, kept in sync via a useEffect on `conversations`.
Event handlers check it synchronously to decide between a cheap
in-place patch and a hydrate refetch — no more closure-flag pattern.
2. Reconnect + visibility resync. Realtime is best-effort: events sent
while the WS was disconnected or while the tab was background-throttled
are gone. A `resyncToken` state bumped on (a) `isConnected` false→true
transitions after the initial connect and (b) tab visibility →
visible drives `ConversationList` and `MessageThread` to refire their
fetch effects. The reactions effect in MessageThread is split into
fetch + channel-subscribe so a resync only refetches the rows
without tearing down and rebuilding the realtime channel.
3. Keep the conv-INSERT hydrate. Still the right fix for #104 (realtime
payloads never carry the `contact` join); now it only fires when the
conversation is genuinely unknown to state.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two follow-ups to the chat-actions PR:
1. Action toolbar was revealing across the whole message row, not just
over the bubble. The `group/actions` div in MessageActions wrapped
the bubble's full-width `flex w-full justify-end/start` row, so
anywhere in that row triggered group-hover. Moved row alignment +
max-w-[75%] cap onto MessageActions and reduced MessageBubble to a
content-sized stack. Hover area now matches the bubble shape.
2. Agent reactions weren't appearing locally. `handleAddReaction` was
wrapped in `useCallback` with a `[]` dep array, so it captured the
first-render `postReaction` — whose own closure held a stale
`reactions = []`, stale `conversation`, and stale `user`. Every
optimistic update replaced the reactions list with `[onlyMyTemp]`
and the API POST may have hit a null conversation. Dropped both
wrappers; postReaction is now called directly from JSX and uses a
functional `setReactions((prev) => ...)` so each call sees fresh
state. Toggle semantics for pill-click are computed at the call site
where `msgReactions` and `user?.id` are already in scope. Realtime
INSERT now swaps a matching optimistic temp row instead of stacking
a duplicate.
Also: reject `temp-*` message IDs with a friendly toast so users can't
react to a bubble that hasn't reached Meta yet.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds three WhatsApp-standard affordances to the inbox: per-message
emoji reactions, reply-with-quote, and copy-text. Bubbles stay pure
presenters — actions layer on by composition (MessageActions wraps
MessageBubble). Hover toolbar on desktop, long-press (onContextMenu)
on touch.
Data model (migration 009):
- messages.reply_to_message_id (self-FK, ON DELETE SET NULL) so replies
survive a deleted parent
- message_reactions table — one row per (message, actor) with a unique
constraint, conversation_id denormalised so Supabase Realtime can
filter without a join
- RLS scoped via conversation ownership; service-role bypass for the
webhook
API:
- /api/whatsapp/send accepts reply_to_message_id, resolves the parent's
Meta message_id and forwards it as context.message_id
- /api/whatsapp/react (new) sends Meta reaction payloads and mirrors
the upsert/delete into message_reactions
- Webhook stops storing inbound reactions as fake text messages and
instead upserts/deletes message_reactions; inbound replies (context.id)
resolve to internal UUID and populate reply_to_message_id
UI:
- MessageActions hover toolbar with inline 6-emoji Popover (static set;
no emoji-picker library)
- ReplyQuote shared between the in-bubble quote and the composer chip
- MessageReactions pill row with click-to-toggle
- Thread subscribes to message_reactions filtered by conversation_id;
optimistic upsert with rollback on failure
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Pro-badged Schedule for Later tile in step 4 was a non-interactive
div — no handler, no scheduling backend, and no Pro tier in the product.
Send Immediately was the only path that did anything, so the picker
itself was decorative. Remove the section (and the now-unused timing
state / Clock / Lock imports). The wizard still completes Send
Immediately end-to-end via the existing Send Broadcast button below.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
handleOpenTemplates flipped templateModalOpen but nothing rendered when
true, so both composer template buttons were dead. Add a TemplatePicker
dialog that lists Approved templates, prompts for any {{N}} variables,
then sends through the existing /api/whatsapp/send route with
message_type=template. The rendered body is stored as content_text so
the thread bubble and conversation preview show the actual message.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the no-op Assign button in the thread header with a dropdown that
reads/writes conversations.assigned_agent_id. Lists profiles visible
under RLS (single-tenant today, ready for shared workspaces) and offers
Unassign when the conversation is already claimed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Forks of this repo no longer inherit the wacrm.tech landing page
and docs — those move to the dedicated marketing-site repo at
https://github.com/ArnasDon/wacrm-site (deployed to wacrm.tech).
Changes here:
- Replace src/app/page.tsx with a redirect to /dashboard.
Middleware already handles the unauth → /login bounce, so this
works for both auth states.
- Drop src/app/{docs,opengraph-image.tsx,robots.ts,sitemap.ts}.
- Drop src/components/{landing,docs,seo} and src/lib/{seo,docs}.
- Drop the top-level docs/ markdown — moved to wacrm-site/docs.
- Slim src/app/layout.tsx metadata down to product chrome
(no public OG cards, robots: noindex).
- Drop `marked` from dependencies; refresh package-lock.
- Refocus README on self-hosting; point readers at wacrm.tech/docs.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per Meta's brand-use guidance for third-party products, sites that
reference WhatsApp must include a clear non-affiliation disclaimer in
the footer. Adds the standard line:
"WhatsApp® is a registered trademark of Meta Platforms, Inc.
Hostinger is not affiliated with, endorsed by, or sponsored by
Meta Platforms, Inc."
Placed in:
- src/components/landing/footer.tsx — marketing footer (every public
landing page).
- src/components/docs/shell.tsx — public docs pages had no footer at
all; added a slim one carrying the same disclaimer.
Out of scope:
- Authed dashboard pages don't have a footer (full-screen workspace
layout). The trademark-fair-use risk is concentrated on public
surfaces — crawlers, visitors who haven't signed up, social shares.
Skipping the dashboard keeps the workspace layout intact and the
disclaimer where it does the most work.
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>
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>
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>
Reports: clicking a conversation sometimes left the message thread
stuck on "No messages yet" until the user hit browser refresh.
Root cause — a three-way race kicked off by any click on a conversation
in the list:
1. handleSelectConversation runs:
setActiveConversation(B)
setMessages([])
router.replace("/inbox?c=B")
2. The URL change flips `deepLinkConvId` (from useSearchParams).
handleConversationsLoaded is a useCallback with
[deepLinkConvId], so its identity changes. That cascades into
ConversationList, whose fetchConversations useCallback has
[onConversationsLoaded], whose useEffect has [fetchConversations] —
so the list refetches conversations purely because the URL changed.
3. When the refetch lands, handleConversationsLoaded runs its deep-link
auto-select branch. autoSelectedForDeepLinkRef still points at its
previous value (null, or the previous deep-link), so the guard
`ref !== deepLinkConvId` passes and the branch re-applies B:
setActiveConversation(B) // no-op, same id
setMessages([]) // BLOWS AWAY the messages
// MessageThread just fetched
4. MessageThread's fetch effect only re-runs when conversationId
changes. B is still B. It does not refetch. Messages stuck at [].
A hard reload unsticks things because state starts from scratch and
the initial deep-link flow is the only path that runs the auto-select.
Three defense-in-depth fixes:
- handleSelectConversation now writes the clicked id into
autoSelectedForDeepLinkRef BEFORE the router.replace, so by the
time any downstream refetch lands, the auto-select branch sees
ref === deepLinkConvId and skips.
- handleConversationsLoaded early-returns if the deep-linked
conversation is already the active one. This is redundant with
the ref update above but protects against any other code path
we don't foresee triggering a refetch.
- ConversationList's fetch is now driven by a plain useEffect with
[] deps, using a ref for the latest onConversationsLoaded
callback. URL changes no longer cause a conversations refetch.
Matches the same stabilisation pattern used in
src/hooks/use-auth.tsx and src/components/inbox/message-thread.tsx.
Verified: typecheck passes; lint count unchanged (37 pre-existing
problems, zero added). Couldn't exercise the rendered /inbox from the
preview sandbox — it's auth-gated and no test credentials are
available. Please confirm in the authenticated inbox:
- Click back and forth between two conversations with different
message histories; messages should populate every time.
- Open /inbox?c=<id> directly; thread opens with messages.
- Switch conversations after a deep-link landing; still works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The \"Type '/' for quick replies\" hint used to live inside the
textarea's flex-1 column. With items-end on the parent row, the
column's height grew by the hint's line box and the buttons aligned
to *that* bottom — sitting one row below the textarea on screen.
Move the hint out to a sibling of the flex row. The row now only
measures the textarea, items-end aligns the buttons level with the
textarea's bottom, and the hint drops neatly below with a pl-11
indent so it stays aligned under the textarea's left edge
(w-9 button + gap-2 = 44px).
Verified structurally via source; the /inbox route itself requires
auth so the preview couldn't exercise the rendered UI. Typecheck
clean.
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>
Renders every file in docs/*.md as a proper documentation site styled
like Stripe / Vercel / Linear docs: sticky header, grouped left sidebar
(Setup / Deploy / Operate), dark prose with inline code pills, syntax
-ready code blocks, responsive table styling, and prev/next pagination.
- src/lib/docs/content.ts — parses md with marked and decorates the
HTML with Tailwind utility classes via post-processing regex. Also
rewrites inter-doc `./other.md` links to `/docs/other` and extracts
an h2/h3 TOC for future use.
- src/app/docs/layout.tsx + src/components/docs/shell.tsx — shared
frame with docs-scoped header (GitHub, Home, Get started), collapsing
sidebar on mobile.
- src/app/docs/page.tsx — grouped index cards per section.
- src/app/docs/[slug]/page.tsx — individual doc + prev/next nav.
- Adds "Docs" to landing nav, footer, and a "Read the full
documentation" CTA under the open-source cards.
- Adds `marked` dependency.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Swaps the Hostinger recommendation on the landing page and throughout the
docs from VPS to Managed Node.js Hosting
(https://www.hostinger.com/web-apps-hosting).
The deploy walkthrough is rewritten around hPanel's Node.js app flow
(Git-based deploy, env vars panel, AutoSSL, cron via Advanced → Cron Jobs)
instead of a raw Ubuntu VPS with nginx + PM2 + certbot. VPS stays
mentioned at the bottom of the deploy page as an alternative when a
user actually needs one (background workers, custom binaries, etc.).
Also touched:
- docs/README.md, environment-variables.md, automations-and-cron.md,
troubleshooting.md to drop VPS-specific assumptions.
- footer link URL.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a #self-host landing section with two cards — one linking to
the GitHub repo (fork / clone) and one recommending Hostinger VPS
for deploying the template. Also surfaces the GitHub link from the
nav (desktop icon + mobile drawer) and the footer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of a series to make the app mobile-friendly. The sidebar used
to be a fixed 240px column always visible, eating most of a 375px
phone screen and leaving ~135px for content. Now:
- Sidebar becomes a slide-in drawer on <lg, static on ≥lg. Controlled
via a drawer-open prop wired from the shell; transform + duration
feels native on touch devices.
- Backdrop overlay closes it on tap; Escape closes it; body scroll
locks while open; route changes auto-close it.
- Header gains a 44×44 hamburger button on <lg that opens the drawer,
hidden on ≥lg. Title shrinks slightly on narrow widths so the
hamburger + avatar + sign-out row never wraps.
- Nav rows are taller (py-2.5) on mobile for comfortable touch
targets, slim (py-2) back on desktop.
- Main content padding is p-4 on mobile, p-6 on desktop so cards
have room to breathe without shouldering into the viewport edge.
No desktop changes visible beyond tighter padding on very narrow
lg breakpoints (main content width is unchanged).
Next PRs in this series will adapt:
- Inbox (3-pane → single-pane with back)
- Pipelines (kanban snap-scroll)
- Automation builder (card widths + bottom-sheet edit)
- Tables + forms polish
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-applies the original SEO work from PR #47 that was reverted in
PR #54 because its opengraph-image.tsx crashed `next build` during
static prerender of /opengraph-image. Satori (next/og's renderer)
errored with:
Error: Expected <div> to have explicit "display: flex",
"display: contents", or "display: none" if it has more
than one child node.
Failed to load dynamic font for ✓ . Status: 400
Root cause in the OG image JSX:
- Several <div>s held text + a sibling element (e.g. "Run your
WhatsApp business from <span>one inbox.</span>") without an
explicit display value. Satori requires it whenever there is
more than one child.
- The ✓ Unicode glyph isn't covered by the default font Satori
loads, so next/og tried to download a dynamic font and failed.
Fix:
- Every <div> with more than one child now has display: flex.
- Multi-span headline sits in a flex-wrap row of two <span>s.
- ✓ replaced with plain text dot separators in the feature row.
Verified end-to-end: `npm run build` now completes with 27/27
pages prerendered, /opengraph-image among them.
All the other SEO-pass content is identical to the originally
merged-and-reverted work: metadataBase + title template, OpenGraph
+ Twitter Cards defaults, JSON-LD (WebSite + Organization +
SoftwareApplication + FAQPage), sitemap.ts, robots.ts, noindex on
auth + dashboard route-group layouts, shared faq-data + site-config
modules.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The automation flow builder had fixed-width cards (320px for steps,
400px for conditions) and a crammed top bar. On a 375px phone the
condition cards overflowed horizontally and the top bar's Active
switch label left the name input with almost no room.
- Step cards: w-full max-w-[320px] sm:w-80 — full-width on mobile,
original 320px on sm+.
- Condition cards: w-full max-w-[400px] sm:w-[400px].
- Trigger card matches the step cards.
- Condition branches (Yes/No) stack vertically on mobile — two
columns at 375 would cram each branch to ~170px. Two columns
return on sm+.
- Top bar tightens on mobile: "Active" label hidden under sm so
the switch + save button + name input all fit; back-arrow grows
from h-8 w-8 → h-9 w-9 for a comfortable touch target; name input
is text-sm on mobile, text-base on sm+.
Desktop unchanged — every mobile-specific class is overridden by
its sm: counterpart.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The pipelines board was already horizontally scrollable, but on a
phone it felt imprecise — swipes landed mid-column, there was no
visual hint that more columns existed past the viewport, and 260px
columns felt cramped at 375px width.
- Each StageColumn is now w-[85vw] min/max-clamped on mobile, so the
next column's edge peeks in as a "more here" affordance. On lg+
the columns revert to flex-1 basis-[260px] (share the row).
- Scroll container gets snap-x + snap-mandatory on mobile, snap-none
on lg+. Each column has snap-start so swipes always land cleanly
at the left edge of the next column.
- shrink-0 on mobile prevents flex from compressing the columns when
space is tight.
No desktop visual change — all the mobile-specific classes are
overridden back by their lg: counterparts, and the original
flex-1 basis-[260px] behavior is preserved above lg.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The inbox was a 3-pane flex row (list + thread + contact sidebar).
On a 375px phone both list and thread tried to share ~187px each
and the whole UI was unusable. Now:
- On <lg the inbox shows ONE pane at a time: list when nothing's
selected, thread when a conversation is open.
- Thread gains a back-arrow in its header (mobile only) that pops
back to the list and clears the ?c= URL param so refreshing lands
on the list, not on the thread the user just backed out of.
- Conversation list grows to w-full on mobile (was fixed w-80)
so each row uses the whole viewport width.
- Thread header tightens: name/phone truncate with min-w-0, session
timer badge is hidden under sm to save room for back arrow + name.
- Dashboard shell's p-6 is already p-4 on mobile (PR #48); matched
the -m-6 offset in the inbox to -m-4 sm:-m-6.
Desktop layout (lg+) is unchanged: list + thread + contact sidebar
side-by-side exactly as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First of a series to make the app mobile-friendly. The sidebar used
to be a fixed 240px column always visible, eating most of a 375px
phone screen and leaving ~135px for content. Now:
- Sidebar becomes a slide-in drawer on <lg, static on ≥lg. Controlled
via a drawer-open prop wired from the shell; transform + duration
feels native on touch devices.
- Backdrop overlay closes it on tap; Escape closes it; body scroll
locks while open; route changes auto-close it.
- Header gains a 44×44 hamburger button on <lg that opens the drawer,
hidden on ≥lg. Title shrinks slightly on narrow widths so the
hamburger + avatar + sign-out row never wraps.
- Nav rows are taller (py-2.5) on mobile for comfortable touch
targets, slim (py-2) back on desktop.
- Main content padding is p-4 on mobile, p-6 on desktop so cards
have room to breathe without shouldering into the viewport edge.
No desktop changes visible beyond tighter padding on very narrow
lg breakpoints (main content width is unchanged).
Next PRs in this series will adapt:
- Inbox (3-pane → single-pane with back)
- Pipelines (kanban snap-scroll)
- Automation builder (card widths + bottom-sheet edit)
- Tables + forms polish
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Brings the site from bare-bones metadata to production-ready SEO.
Covers technical crawlability, rich-result eligibility, social
sharing, and duplicate-content prevention.
Added:
- src/lib/seo/site-config.ts — single source of truth for base URL,
brand, description, keywords. Referenced by every consumer so a
domain / rename is a one-file edit.
- src/lib/seo/faq-data.ts — FAQ Q&A extracted from the component so
the UI and the FAQPage JSON-LD render the same content.
- src/lib/seo/structured-data.ts — builders for WebSite,
Organization, SoftwareApplication, and FAQPage JSON-LD nodes.
FAQPage is eligible for Google's FAQ rich result; SoftwareApplication
with a free Offer can earn a "Free" badge on SERP.
- src/components/seo/json-ld.tsx — emits one <script
type="application/ld+json"> per node (Google's preferred shape).
- src/app/opengraph-image.tsx — Next auto-generates a 1200×630 PNG
for the landing page's OG / Twitter previews.
- src/app/sitemap.ts — lists only public, indexable pages.
- src/app/robots.ts — Allow /, Disallow every app / auth route,
references the sitemap.
Updated:
- src/app/layout.tsx — metadataBase, title template (%s — WaCRM),
default OpenGraph + Twitter Cards (inherited everywhere), canonical,
icons config, viewport (themeColor + colorScheme=dark for browser
chrome on mobile).
- src/app/page.tsx — injects the 4 JSON-LD blocks, sets explicit
canonical for /, overrides the title.
- src/app/(auth)/layout.tsx — noindex+nofollow for login / signup /
forgot-password (no SEO value; would compete with landing page).
- src/app/(dashboard)/layout.tsx — split into thin server layout
(exports noindex metadata) + DashboardShell client component. App
routes won't be indexed even if a URL leaks.
- src/components/landing/faq.tsx — consumes the shared FAQ data
module (no more drift between UI and JSON-LD).
Verified end-to-end against the running dev server:
- GET / HTML contains: canonical, description, keywords, robots,
og:title/url/image/type, twitter:card/title, theme-color, + 4
JSON-LD blocks (WebSite, Organization, SoftwareApplication,
FAQPage).
- GET /opengraph-image → 200 OK, content-type image/png.
- GET /login HTML contains <meta name="robots" content="noindex,
nofollow, nocache">.
- GET /sitemap.xml serves valid XML with /.
- GET /robots.txt serves Allow /, Disallow all app + auth paths,
sitemap reference.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the root redirect to /login with a real marketing landing so
first-time visitors can learn about WaCRM and convert. Authed users
still see it and get a "Go to Dashboard" CTA instead of "Sign in" /
"Get started".
Sections, in order:
- Sticky nav with smooth scroll to Features / How it works / FAQ.
Auth-aware CTA: pending/signed-in/signed-out states render the
right buttons without layout shift.
- Hero with clear one-liner, benefit subhead, primary + secondary
CTA, trust cues, and a miniature inbox preview (pure CSS/SVG
recreation of the real UI — no screenshot to age out).
- Features grid: 6 benefit-first cards.
- 4x feature spotlights (alternating): shared inbox, automations,
pipelines, analytics. Each with a bespoke product miniature.
- How It Works: 3 steps framed around "live in 30 minutes".
- FAQ: 6 questions answering the real objections (BSP access, team
sharing, setup speed, data ownership, templates, broadcasts).
- Final CTA banner + footer with nav + product/account link columns.
Implementation notes:
- All visuals are CSS/SVG miniatures in src/components/landing/mock —
intentionally not real screenshots so the page doesn't drift from
the product over time.
- Dark theme throughout to preserve the app's brand (per the repo's
dark-only convention). No light sections.
- Per-viewport verified: desktop (1440x900) two-column hero with
mock; mobile (375x812) stacks hero and reveals a hamburger menu.
- Middleware untouched — the page itself did the /login redirect;
replacing page.tsx is enough.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Hover was off by ~one letterbox width on wide layouts — users had to
drag the cursor far right of the last data point to see its tooltip.
Root cause: the SVG uses a fixed 760×240 viewBox with preserveAspect-
Ratio's default "xMidYMid meet", which centers the content and leaves
empty letterbox bars on either side when the container is wider than
the viewBox aspect. The old hover math mapped clientX linearly onto
the full SVG DOM rect, so every pixel of letterbox shifted the snap
further from the real data point.
Switched to SVGSVGElement.getScreenCTM().inverse() to map screen
coords back to viewBox units correctly regardless of letterboxing,
scaling, or future transforms. The tooltip's horizontal offset also
now comes from the same CTM (mapping the snapped data point's viewBox
x back to a pixel offset inside the wrapper div), so the tooltip and
crosshair line up exactly on top of each other.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With real data the feed showed 20 rows by default and filled most of
the viewport. Switched to a 5-row default with an inline size picker
(5 / 10 / 20 / 50) so users choose how much history is visible.
- Dashboard page bumps the fetch from 20 → 50 so every size option is
already in memory. Switching sizes is a pure client-side slice, no
round trip.
- ActivityFeed owns the pageSize state, renders "Showing N of M"
plus a button group in the footer. The smallest option is always
enabled; larger options unlock only once we have enough rows to
meaningfully reveal more (e.g. "10" enables once loaded > 5).
- Existing "View all" header link left alone.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
With grid items-stretch (the default) the two cards in the charts
row try to share the tallest sibling's height — but the Pipeline
Value card was rendering at its natural (shorter) height because
neither its col-wrapper nor its inner section had h-full. So the
line chart's taller content drove the row but the pipeline card
hung at the top with trailing whitespace.
Added h-full on both col wrappers and flex h-full flex-col on
each inner section so they actually fill the row. The existing
flex-1 wrapper inside PipelineDonut now has a defined height to
stretch against, so the empty state (and the populated donut +
legend) span the card cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the dashboard page as a data-driven analytics surface using
existing Supabase tables only — no schema changes, no other pages
touched.
Widgets (all render independent skeletons while their data fetches,
and show a shared empty state when there's not enough data to be
useful):
- Metric cards (Active Conversations, New Contacts Today, Open Deals
Value, Messages Sent Today) with up/down/no-change delta indicator.
- Quick actions row (New Contact / Deal / Broadcast / Automation).
- Conversations Over Time: SVG line chart, 7/30/90-day tabs, per-day
hover crosshair + tooltip. Cached per range so tab switches are
instant.
- Pipeline Value: SVG donut with per-stage arc segments, total in the
center, legend with deal count + value.
- Average First Response Time: SVG bar chart by weekday with a
configurable dashed target line and this-week / last-week summary.
- Activity feed: latest 20 items merged across messages, contacts,
deals, broadcasts, and automation logs — click-through deep-links
where applicable.
Deviations from spec, noted up-front:
- Strictly dark theme — spec's #f9fafb alternating rows translate to
slate-900 / slate-900/40 stripes.
- Kept Inter (tabular-nums, bold 28px) for metric values instead of
Space Grotesk to avoid touching root layout.
- Quick actions link to the relevant list pages (/contacts, etc.)
rather than auto-opening their create modals — that would require
modifying those pages.
All aggregation is client-side via RLS-scoped queries. If a tenant's
dataset outgrows a few thousand messages per range window, the
response-time and line-chart queries should migrate to SQL RPCs —
noted as follow-up, not shipped here.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Activating an automation with obviously-broken config (add_tag step
with empty tag_id, send_message with empty text, keyword_match
trigger with no keywords, etc.) used to succeed — and then every
dispatch produced a cryptic failed log row that users often missed.
- New validate.ts centralises the checks (mirrors engine.ts's
runtime guards one layer earlier so failures surface at save time).
Returns { path, message } issues so the UI can point users at the
exact problem.
- POST /api/automations rejects with 400 when is_active === true and
the merged config fails validation.
- PATCH /api/automations/[id] computes the post-patch "effective"
state (merging body fields over the existing row, loading current
steps if body.steps isn't provided) and applies the same gate.
Drafts (is_active=false) remain free to be incomplete so users can
still save progress mid-build.
- Builder surfaces the first issue in a toast with path as the
description, so users don't need DevTools to find what to fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two issues:
1. Unread count didn't always clear when opening / viewing a
conversation. The reset effect keyed off conversationId only, so
a new inbound message arriving while the thread was already open
would get the server to bump unread_count to 1, but the reset
effect wouldn't re-fire. Split the fetch and unread-reset effects:
fetch stays keyed on conversationId (no unnecessary refetches on
every new message), and a second effect keyed on [conversationId,
hasUnread] flips unread back to 0 whenever it surfaces on the
active conversation. The hasUnread gate still prevents the
reset→update→realtime→re-run loop.
2. No indicator elsewhere in the app when new messages arrive. Added
useTotalUnread() hook — one-time query + realtime subscription on
the conversations table — and a pulsing green dot on the sidebar
Inbox entry when totalUnread > 0. Dot is hidden while on /inbox
itself (redundant with the per-conversation badges there).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>