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>
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, 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>
CBC without a MAC is unauthenticated — anyone who can write rows to
whatsapp_config can flip bits in the stored access_token or
verify_token without the decrypt throwing. You'd silently get garbled
bytes; worst case, if the mutation happens to produce a valid token,
messages go out under a spoofed account. GCM appends a 16-byte
authentication tag; any tampering fails the decrypt.
## What changed
src/lib/whatsapp/encryption.ts
- encrypt() now emits AES-256-GCM ciphertexts with a 12-byte IV,
formatted as `<iv>:<ciphertext>:<authTag>` (three colons).
- decrypt() auto-detects format by splitting on `:`:
3 parts → GCM, validated via auth tag.
2 parts → legacy CBC, read-only compatibility path.
Callers are unchanged; same signature, same return type.
- isLegacyFormat() exposes the format check so hot paths can
opportunistically upgrade old rows.
- IV length is validated on decrypt (12 for GCM, 16 for CBC) to
fail fast on corrupted input.
## Self-healing existing rows
Two call sites opportunistically upgrade a legacy row to GCM after a
successful decrypt — fire-and-forget, no latency impact on the request
path, idempotent under concurrent writes.
- src/app/api/whatsapp/send/route.ts (hot path)
After decrypting access_token, if it was CBC, we schedule an
UPDATE that writes a fresh GCM ciphertext back to the same row.
The first send after deploy upgrades the token; subsequent
sends are no-ops because isLegacyFormat returns false.
- src/app/api/whatsapp/webhook/route.ts (subscribe handshake)
When a config's verify_token matches the inbound
hub.verify_token, we upgrade it in place if it was legacy.
Runs during Meta's webhook re-subscription events, which
happen on config changes and periodic refreshes.
No schema migration required — the DB column holds whatever string
encrypt() produces. Operators who want to force an upgrade for a
particular user can clear and re-save the WhatsApp settings form.
## Verified (tsx smoke test)
- GCM roundtrip works (plaintext in → same plaintext out).
- Legacy CBC decrypt works on 2-part format.
- isLegacyFormat distinguishes new/legacy correctly.
- Bit-flipped GCM ciphertext throws on decrypt.
- Forged auth tag throws on decrypt.
- Same plaintext produces different ciphertexts across calls
(IV randomness).
## Rollback
Revert this file. Existing GCM rows would then fail to decrypt
because the previous encrypt.ts has no branch for the 3-part format.
Safer rollback: keep the decrypt branches, revert only the encrypt()
body to CBC — but since GCM roundtrips cleanly and the PR's bug
surface is small (pure crypto utility), a straight revert should only
matter if a forker had time between merging this PR and reverting to
accumulate GCM rows.
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>
A fork running WaCRM publicly had no server-side guard on the
WhatsApp send endpoints — a compromised session or an aggressive UI
loop could call /api/whatsapp/send or /broadcast as fast as the
network allowed and blow through Meta's per-number quota (or worse,
get the number suspended).
This PR adds a minimal in-memory fixed-window rate limiter keyed by
user id.
Budgets (tuned for a template default — forkers can edit):
- /api/whatsapp/send 60 per minute per user (1/s sustained)
- /api/whatsapp/broadcast 5 per minute per user (campaign dispatch
rate, not fan-out)
Behaviour:
- Check runs right after the Supabase auth check; unauthenticated
calls 401 as before and never consume a bucket entry.
- When a user exceeds the budget the route returns 429 with
Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining,
X-RateLimit-Reset headers.
- Send and broadcast buckets are independent; two users are
independent.
Design trade-off:
The limiter lives in a process-local Map. On a single Hostinger
Managed Node.js instance — the template's default deployment —
that's the whole limit. On a horizontally-scaled deploy (multiple
regions, serverless fan-out) each instance has its own Map and the
effective rate is N * limit. Swap src/lib/rate-limit.ts for a
Redis/Upstash/Durable-Objects backend if you scale past one
instance; keep the checkRateLimit return shape and the call sites
don't move.
Memory is self-cleaning: a 1-in-1000 opportunistic sweep drops
expired keys without a background timer, so the Map stays bounded
without running anything outside the request path.
Smoke test (tsx):
- 7 broadcasts → 5 ok, 2 denied.
- Different user → independent bucket.
- Same user, different route → independent bucket.
- Retry-after = 60s.
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>
layout.tsx still had `icons.shortcut: ["/favicon.ico"]`, which kept
emitting the default Next.js favicon link in <head> even after the
file was deleted. icon.tsx is the single source now.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
We already have src/app/icon.tsx generating the WaCRM emerald mark,
but the default create-next-app favicon.ico was still in src/app/
so Next.js kept emitting <link rel="shortcut icon" href="/favicon.ico">
in <head>. Browsers prefer the ICO, so tabs still showed the default
Vercel triangle on the landing page.
Removing favicon.ico leaves icon.tsx as the sole source, which is
what the comment in that file already claimed.
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>
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>
Every column rendered at every width, relying on horizontal scroll.
A 375px phone had to swipe sideways just to read a name. Matches
the tiering contacts already uses:
- Mobile (<sm): Name + Status only.
- sm+: adds Recipients, Date.
- md+: adds Template.
- lg+: adds Delivery + Read rate bars.
Desktop (≥lg) unchanged — all seven columns visible as before.
Salvaged from the closed PR #52 — the OG-image portion of that PR
is already on main via PR #55, so only the broadcasts-table change
is shipped here.
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 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>
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>
Clicking a row in the dashboard's "Recent Conversations" routed to
/inbox with no hint about which conversation to open, so the user
landed on the empty center panel and had to find the thread again.
- Dashboard now routes to /inbox?c=<conversation_id> for per-row clicks.
"View all" still goes to plain /inbox.
- Inbox page reads ?c=<id> and auto-selects the matching conversation
in handleConversationsLoaded (event handler, not an effect, so
react-hooks/set-state-in-effect stays happy). A ref guards against
resnapping the selection on later realtime list refreshes.
- Inbox also now replaces the URL with ?c=<id> when the user picks a
conversation, so a page refresh / shared link lands on the same
thread.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
new_contact_created only fires when the webhook auto-creates the
contact row, so it misses the case of manually-added / imported
contacts sending their first-ever message.
first_inbound_message fires whenever the incoming message is the
contact's first customer-sent message (checked by counting prior
customer-sender messages in the conversation before insert), which
covers both auto-created and pre-existing contacts.
- Added first_inbound_message to AutomationTriggerType.
- New pill color + dropdown entry labeled "First Message from Contact".
- Webhook now counts prior customer messages before inserting the
incoming one; dispatches first_inbound_message when the count is 0.
- new_contact_created still fires for brand-new contacts; they're
independent triggers with overlapping coverage, so users pick.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
send_message and send_template steps were inserting a row into
messages with status='sending' but never calling Meta's WhatsApp API,
so automation replies appeared in the inbox but never reached the
phone. send_template was a plain noop ("template send recorded").
- New helper src/lib/automations/meta-send.ts mirrors the logic in
/api/whatsapp/send (decrypt config, phone-variant retry, persist
messages row with status='sent' and the real whatsapp message id).
Kept as a separate helper to avoid touching the working manual-send
route; the two can converge in a later refactor.
- Engine's send_message / send_template now await the helper and
surface Meta errors through the existing per-step failure path,
so a bad token / invalid template shows up in automation_logs with
status='failed' instead of silently "sending" forever.
- Consolidated automation dispatches in the webhook to fire after the
conversation is created. Previously new_contact_created raced the
conversation insert, which could have made send_message fail to
resolve a target conversation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the /automations placeholder with a full feature:
- Migration 006 adds automations, automation_steps, automation_logs,
and automation_pending_executions with RLS matching existing tables.
- List page with active/inactive switch, quick-start templates,
duplicate, delete, and View Logs actions.
- Full-screen vertical flow builder with Trigger + Action + Condition
(Yes/No branches) + Wait cards. Inline edit forms, up/down reorder,
dotted-grid canvas.
- Execution engine (src/lib/automations/engine.ts) dispatched from the
WhatsApp webhook for new_message_received, new_contact_created, and
keyword_match. Wait steps enqueue to automation_pending_executions;
/api/automations/cron drains them behind AUTOMATION_CRON_SECRET.
- Logs viewer with per-step checkmarks and expandable detail.
Existing files touched only in webhook/route.ts (fire-and-forget
trigger dispatch) and types/index.ts (appended automation types).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes 8 real issues found during a thorough review of the broadcast
feature (PRs #21-#29).
Security
* Webhook POSTs had zero spoof protection. Anyone could forge a
status update and drift broadcast counts arbitrarily. Added
HMAC-SHA256 signature verification via the X-Hub-Signature-256
header (Meta's standard). New helper src/lib/whatsapp/webhook-
signature.ts with timing-safe comparison. Rollout fails open
with a warning when META_APP_SECRET is unset so existing
deploys don't break at rollout; set the env var to enforce.
Correctness
* CSV audience was completely broken — synthesized "csv-N" strings
as contact_id, which failed the UUID cast on insert. Every CSV
broadcast silently created zero recipients. Replaced with a
real find-or-create against the contacts table (batched lookup
by phone, single INSERT for missing rows, de-duplicates CSV
inputs). CSV contacts now end up as real contacts.rows with
real UUIDs flowing through the rest of the pipeline.
* Recipient insert errors were logged with console.error and the
loop marched on — broadcasts then ran with incomplete recipient
sets, webhook updates couldn't find rows, and aggregate counts
drifted. Now: on batch insert failure, flip the broadcast to
failed + failed_count=total + throw so handleSend's toast fires.
* Webhook status guard ordered "failed" above "replied", which
meant a webhook replay delivering "failed" for an already-
replied recipient would upgrade replied → failed. Split the
ladder: forward-only ordering across pending → sent →
delivered → read → replied; failed is a terminal side branch
that can only be reached from pending or sent (never from any
success state). Net effect: a message that was read can no
longer regress to failed on a replay.
* Template sync only pulled page 1 (ignored paging.next). Users
with >100 templates silently missed some, and broadcasts with
those names failed with Meta's "template not found". Now
follows paging.next with a 20-page safety cap, reports
truncated=true in the response if hit.
* Template sync reported success:true even when individual
upserts failed. Now accumulates per-template errors and
surfaces them via a separate toast.error in the UI — green
toast no longer hides silent drift.
Performance
* Migration 005: replace the broadcast_recipients aggregate
trigger installed in migration 003. Old version ran
COUNT(*) FILTER on every row change — O(n²) across a send
loop (10k recipients × 10k index scans). New version
increments / decrements by ±1 per status transition: O(1)
per change. Kept the old recompute_broadcast_counts() as a
callable safety-net in case of drift.
* List page poll kept running while the tab was hidden. Added
visibilitychange handler — polling pauses when hidden,
resumes (and does an immediate fetch) on refocus.
UX
* Step 3 (Personalize) let users skip past unmapped placeholders
→ broadcasts then sent to Meta with empty-string params.
Next is now disabled until every {{N}} has a value; an amber
warning lists the missing placeholders.
Documented accepted risks in the PR body.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses two recurring issues:
1) Delete broadcast campaign
Broadcast detail page gets a Delete button (red, in the header, next
to the status badge) with an inline confirm pattern matching
pipeline-settings. Disabled while status='sending' — we don't want
to orphan in-flight Meta messages. On confirm, supabase delete
cascades broadcast_recipients and redirects to /broadcasts.
2) Persistent Meta error #132001 "Template name does not exist in the
translation"
Root cause that wasn't fixed by PR #27's language default: the local
template manager never actually submitted templates to Meta. Users
would create welcome_message locally, try to broadcast, and get
rejected because Meta had never seen the template.
Meta template submission requires a human review flow in Meta's
WhatsApp Manager — we can't automate that from inside WaCRM. What
we CAN do is pull Meta's approved list so the local catalog never
drifts from reality.
New: POST /api/whatsapp/templates/sync
- Reads whatsapp_config for waba_id + access_token (decrypted)
- GET /v21.0/{waba_id}/message_templates?limit=100 with BODY/HEADER/
FOOTER components
- Normalizes Meta's UPPER_SNAKE category/status to our TitleCase
- Upserts each row in message_templates by (user_id, name, language)
- Locally-created rows with no Meta counterpart stay visible so the
user can notice drift and clean up
Template manager UI: "Sync from Meta" button next to New Template,
with a spinning refresh icon while in flight and a toast summarizing
how many inserted / updated. Header copy clarifies that approval
happens in Meta's WhatsApp Manager.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1) Favicon
Next.js was serving its default triangle icon because src/app/
favicon.ico was the stock placeholder. Replaced with a dynamic
icon route (src/app/icon.tsx) that renders the sidebar mark —
emerald-500 rounded square with a white MessageSquare glyph.
Next.js auto-injects <link rel="icon"> pointing to the generated
PNG.
2) Template language default
Users hit Meta error #132001 "Template name does not exist in the
translation" because the template manager defaulted new templates'
language to "en" while Meta's WhatsApp templates are typically
approved under "en_US". Meta treats "en" and "en_US" as distinct
language codes — a template approved under one is rejected if sent
with the other.
Fixes:
- Default template form language to "en_US" (matches the DB
default on message_templates.language and the broadcast
sender's fallback).
- Swap the freeform <Input> for a datalist-backed input
autocompleting the ~17 most common Meta language codes.
- Inline help text: "Must match the exact language code the
template is approved under on Meta — e.g. en_US and en are
distinct."
For the user's existing welcome_message template (language="en"),
the fix is to edit it in Settings → Message Templates and change
Language to whatever Meta shows in the WhatsApp Manager
(typically en_US). One-time data fix.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The INSERT into broadcasts in use-broadcast-sending.ts never set
user_id, but broadcasts.user_id is NOT NULL + RLS-guarded. The
INSERT rejected (23502 / 42501), the hook threw, handleSend caught
with console.error only. UI showed nothing — the wizard stayed on
Step 4 and the flow appeared dead.
Fix:
- Resolve session.user at the top of createAndSendBroadcast and
include user_id on the broadcasts INSERT.
- handleSend in /broadcasts/new now toasts the error via sonner so
future failures are visible immediately instead of vanishing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Stacked on PR B (#22). Merge A → B → C in order.
Step 2 — Audience
- Implement the "Custom Field" audience option (was a stub): field
picker + operator (is / is not / contains) + value input. Estimated
count query joins contact_custom_values with the operator mapped
to eq / neq / ilike. PostgREST ilike handles "contains" case-
insensitively.
- New "Exclude contacts with these tags" section — applies to every
audience type. Estimated count subtracts excluded contacts
client-side against the resolved base set.
- Switching audience type clears stale shape fields (tagIds /
customField / csvContacts) so old selections don't leak across.
Step 3 — Personalize
- Add "Custom Field" mapping option alongside static + contact field.
- Live Preview now pulls the most recent real contact (+ their custom
values) instead of hard-coded sample data. Falls back to sample if
the user has no contacts yet. Rendered in a WhatsApp-style bubble
so the user gets a realistic feel for how the recipient sees it.
Hook — use-broadcast-sending
- AudienceConfig gains customField + excludeTagIds.
- resolveAudience: custom-field branch + exclude-tag subtraction
applied after the base resolution (works for all / tags / custom
field).
- resolveVariables accepts a pre-built (fieldId → value) map per
contact. Before the send loop we bulk-fetch contact_custom_values
for all resolved contacts in one query (paged by 500 for PostgREST
limits) so we never N+1 per recipient.
new/page.tsx — passes customField + excludeTagIds into the hook;
variables state widens to allow type='custom_field'.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
UI polish for the Broadcasts feature. Stacked on PR A (correctness).
List page /broadcasts
- Replaces raw Sent/Delivered/Read/Failed columns with Delivery Rate +
Read Rate cells (percent + inline mini progress bar). Raw counts
still available on the detail page.
- Sending badge gets a live-looking pulsing dot.
- Indeterminate top-of-page progress bar appears while any broadcast
has status='sending'. Page polls the broadcasts table every 5 s
while that condition holds, and stops polling otherwise.
- Mobile: table wrapped in overflow-x-auto.
Detail page /broadcasts/[id]
- Adds a Replied stat card (was missing). Final grid: Total / Sent /
Delivered / Read / Replied / Failed — 6 cards on desktop, 2 on
mobile. Color spectrum matches the spec's funnel gradient.
- Pure-CSS horizontal funnel chart: Sent → Delivered → Read → Replied
with decreasing widths in emerald → teal → blue → indigo. Each bar
shows its absolute value and percent of Sent.
- Status filter dropdown above the recipients table. Filters
client-side; header reflects filtered / total.
- "Export CSV" downloads all recipients with status + every milestone
timestamp + error_message. RFC-4180 quoting.
- Recipients table gains Delivered / Read timestamp columns so the
CSV columns are also visible at a glance.
Wizard Step 4
- Save as Draft button next to Send Broadcast. Persists a broadcasts
row with status='draft' and zero recipients — enough for the user
to recognize the draft in the list. Full resume-into-wizard is a
future follow-up.
- Confirmation modal before send was already in place; kept as-is.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three correctness bugs were making the existing broadcast flow wrong,
not just incomplete.
1. Per-recipient variables were broken
use-broadcast-sending.ts computed templateParams per contact but shipped
only templateParams[0] to the API, so every contact in a batch received
contact 0's personalization (name/email/etc).
Fix: extend POST /api/whatsapp/broadcast to accept a per-recipient
shape `{ recipients: [{ phone, params }], ... }`. Legacy
`{ phone_numbers, template_params }` still accepted. Hook sends per-
recipient payloads; API returns whatsapp_message_id per phone.
2. Webhook never updated broadcast_recipients
handleStatusUpdate in webhook/route.ts only mirrored Meta status onto
the `messages` table. broadcast_recipients had no correlation column,
so delivered_count / read_count / replied_count never advanced for
real broadcasts.
Fix (migration 003):
- broadcast_recipients.whatsapp_message_id (+ UNIQUE index) for
Meta-message-id correlation
- composite index (broadcast_id, status) for fast aggregate scans
- AFTER INSERT/UPDATE/DELETE trigger that recomputes parent
broadcasts counts via COUNT(*) FILTER — sender + webhook only
touch the recipient row, aggregates stay consistent
Webhook:
- handleStatusUpdate also updates broadcast_recipients with forward-
only status guard (read cannot regress to sent on a replay)
- processMessage flags the replied recipient row when an inbound
message arrives from a recent broadcast contact
3. Rate limiting did not match spec
Batch 50 / no delay → Batch 10 / 1s between batches, matching the spec
and Meta's per-phone-number rate ceiling.
Also: extract statusConfig from both page components into
src/lib/broadcast-status.ts (single source of truth for PR B). Add
whatsapp_message_id to BroadcastRecipient type.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three follow-ups to the Pipelines page that shipped in #18:
1. Analytics strip now sits ABOVE the Kanban board rather than below, so
top-line numbers read first and the board flows beneath.
2. Re-theme the board to match the rest of the dark app: slate-900 columns
with slate-800 borders, slate-800 deal cards with stage-colored accent
bar, emerald dollar values, and slate-500/slate-400 secondary text. The
empty-column "Drop a deal here" and drop-zone highlight stay but in
slate/emerald tones instead of white/light-gray.
3. Each analytics metric now has an info icon + tooltip explaining how it
is calculated in plain language (e.g. Weighted Value explains the
10% → 90% stage interpolation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebuild the Pipelines page to match spec.
Highlights:
- Drag-and-drop deal cards between stages via @dnd-kit (200ms drop animation,
emerald dashed drop zone, opacity fade while dragging)
- First-visit seed: auto-creates "Sales Pipeline" with spec-defined stages
(New Lead / Qualified / Proposal Sent / Negotiation / Won)
- Top bar: always-visible pipeline dropdown with "Manage Pipelines" option,
plus "Add Pipeline" and emerald "Add Deal" buttons
- Deal form converted from Dialog to right-side Sheet, adds assigned_to
(profile FK), "Link to Conversation" shortcut, "Mark as Won/Lost" status
buttons, and "Delete Deal"
- Pipeline settings modal: drag-to-reorder stages (@dnd-kit sortable),
per-stage color picker, "Create new pipeline" shortcut, archive
confirmation copy
- Analytics bar below board: total deals, pipeline value, avg deal size,
weighted pipeline value (probability interpolated across stages),
won/lost this month
- Contact detail sheet gains a "Deals" tab showing all deals for that
contact (value + stage badge + status)
- Migration 002: adds deals.assigned_to FK→profiles, normalizes status
CHECK to ('open','won','lost')
Note: spec called for light-gray Kanban columns (#f9fafb) + white cards
inside an otherwise-dark app. Followed spec literally; easy to retheme.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Selecting the already-active conversation called setMessages([]) but the
fetch effect in MessageThread only re-runs when conversationId changes,
so messages stayed empty until the user left the page and came back.
Bail out of handleSelectConversation when the clicked conversation is
already active.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Over the course of the project the same swapped-args bug was found
four separate times against the Meta API helpers (config in PR #8,
send in PR #12, broadcast in PR #13, webhook media in PR #14). Each
time the compiler accepted the call because all the args were
strings, so the bug only surfaced at runtime as an opaque Meta error.
This refactor eliminates the entire bug class by converting every
helper to a single options object. A typo like
sendTextMessage({ accessToken: id, phoneNumberId: tok, ... })
is now a TypeScript error at the call site rather than a 400 from
Meta two layers deep.
Helpers updated in src/lib/whatsapp/meta-api.ts:
- verifyPhoneNumber({ phoneNumberId, accessToken })
- sendTextMessage({ phoneNumberId, accessToken, to, text })
- sendTemplateMessage({ phoneNumberId, accessToken, to, templateName, language?, params? })
- getMediaUrl({ mediaId, accessToken })
- downloadMedia({ downloadUrl, accessToken })
Each exported Args interface so routes can type local variables
holding partial argument sets (useful for the broadcast retry loop).
Also factored the duplicated "parse Meta error JSON" block into a
shared throwMetaError() helper — was repeated in 5 places.
Call sites updated (all were already correct after prior fixes, but
now the types enforce it):
- src/app/api/whatsapp/config/route.ts (GET + POST)
- src/app/api/whatsapp/send/route.ts (attempt() closure)
- src/app/api/whatsapp/broadcast/route.ts (retry loop)
- src/app/api/whatsapp/webhook/route.ts (verifyAndBuildUrl)
- src/app/api/whatsapp/media/[mediaId]/route.ts (proxy pipeline)
No behavior change. Build passes.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Incoming photos showed up as empty bubbles in the Inbox (only the
timestamp, no image). Investigation found three compounding bugs in
the media pipeline:
1. Swapped getMediaUrl args in webhook parseMessageContent
The helper signature is getMediaUrl(mediaId, accessToken), but every
media case (image/video/document/audio) called it as
getMediaUrl(accessToken, message.X.id). Meta got requests for
GET /v21.0/<ACCESS_TOKEN>
with Authorization: Bearer <MEDIA_ID>
Every verification failed → the catch block returned mediaUrl: null
→ the message was stored in the DB with no media_url → MessageBubble
had nothing to render.
Refactored to a single verifyAndBuildUrl(mediaId) helper called per
media type, with the arguments in the correct order.
2. No fallback UI when media_url is null
MessageBubble's image/video/audio/document cases all did
`{message.media_url && <render />}` with no else branch. So a media
message without a URL rendered an empty <div> — the user saw only
the timestamp.
Added a <MediaUnavailable label="..."> component that shows the icon
+ "Image/Video/Audio/Document unavailable" text when media_url is
null. Applied to all four media types.
3. Sticker type would fail the CHECK constraint on insert
WhatsApp sends image stickers as type: "sticker", but the
messages.content_type CHECK constraint only allows:
text, image, document, audio, video, location, template
Inserting content_type: 'sticker' silently failed.
processMessage now validates content_type against an allow-list
before insert. Stickers are mapped to 'image' (they ARE images).
Reactions and any other unknown type are mapped to 'text'.
Also added the sticker media handling to parseMessageContent so sticker
images actually render (with the verified proxy URL).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Meta WhatsApp returns error #131030 ("Recipient phone number not in
allowed list") in two situations:
1. Sandbox: the recipient isn't registered as a test number.
2. The registered number format differs from what we sent, usually
because of trunk-prefix 0 quirks (e.g. Lithuanian numbers stored
as +370063949836 vs international +37063949836).
Today that error bubbles up to the UI and the user has to manually
edit the contact. This PR makes the send pipeline self-heal: on 131030
it retries with alternate phone-number formats, and if an alternate
works it persists the corrected format back to the contact so future
sends are a single API call.
src/lib/whatsapp/phone-utils.ts
- Added phoneVariants(sanitized) — yields original, then variants with
a trunk 0 inserted/removed after each plausible country-code length
(1-3 digits). Deduplicated. Original is always first so the fast
path for correctly-formatted numbers is one attempt.
- Added isRecipientNotAllowedError(message) helper — matches
code 131030 or the English error text, so we only retry when the
failure is actually about the recipient (other errors fail fast).
src/app/api/whatsapp/send/route.ts
- Reply send now iterates phoneVariants() and stops at the first that
succeeds. If a non-original variant works, contacts.phone is updated
to the working format.
src/app/api/whatsapp/broadcast/route.ts
- Same retry applied per recipient in the broadcast loop.
- Also fixed a separate bug: broadcast was calling
sendTemplateMessage(accessToken, phone_number_id, ...) — wrong order.
Correct signature is (phoneNumberId, accessToken, to, templateName,
language?, params?). This was silently rejecting every broadcast.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Two silent bugs in the send pipeline that made agent messages
never reach WhatsApp AND disappear on page reload:
1. /api/whatsapp/send passed Meta API args in the wrong order.
The helpers have signature (phoneNumberId, accessToken, to, ...)
but the route was calling sendTextMessage(accessToken, phoneNumberId,
to, text). So the request went to
POST https://graph.facebook.com/v21.0/<ACCESS_TOKEN>/messages
with Authorization: Bearer <PHONE_NUMBER_ID>
which Meta rejects. The try block caught the error and the outer
catch returned a generic "Failed to send message" with no detail.
2. Even if Meta had succeeded, the DB insert would have failed because
the route was using field names that don't exist in the messages
table — same class of bug as the webhook had before PR #10:
user_id → not a column on messages
whatsapp_message_id → should be message_id
direction → should be sender_type ('customer'|'agent'|'bot')
message_type → should be content_type
timestamp → should be created_at
Insert would have failed, DB row would never exist, and the optimistic
message in the UI would vanish on the next page load / fetchMessages.
3. The frontend swallowed errors silently (console.error only), so the
UI looked like the message had been sent even when nothing reached
Meta and nothing was saved.
Changes:
src/app/api/whatsapp/send/route.ts
- Swap Meta API args to the correct order
(phoneNumberId first, then accessToken)
- Wrap the Meta call in its own try/catch so the error message from
Meta bubbles up to the client (returned as 502 with the actual text)
- Fix messages insert to use schema-correct columns: sender_type,
content_type, content_text, media_url, template_name, message_id,
status. Dropped non-existent user_id/direction/message_type/timestamp.
- Also surface the underlying DB error if the insert fails.
src/components/inbox/message-thread.tsx
- Added onUpdateMessage prop.
- handleSend parses the error JSON, toasts the actual reason, and
flips the optimistic bubble's status to 'failed' so MessageBubble
renders the red XCircle. On success flips to 'sent' immediately
(realtime INSERT still replaces the temp bubble with the real row).
src/app/(dashboard)/inbox/page.tsx
- Added handleUpdateMessage that mutates one message by id.
- Passed as onUpdateMessage to MessageThread.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three issues in the Inbox page:
1. "WhatsApp not connected" banner always showed even when connected.
The connection check queried .from("whatsapp_configs") (plural) but
the actual table is whatsapp_config (singular). Query always returned
no rows, so status was never "connected". Changed to the correct
table name and also switched .single() → .maybeSingle() so a missing
row returns null cleanly instead of throwing PGRST116.
2. The banner overlapped message content — it used absolute positioning
at top-14, left-60, which floated over the thread's header. Restructured
the page layout so the banner is part of the flex column (pushes panels
down) instead of overlapping them.
3. The inbox "kept refreshing every second" — an infinite loop between
MessageThread's fetch and the realtime subscription in the page:
MessageThread useEffect depended on a fetchMessages callback that had
[conversation, onMessagesLoaded] deps. After fetching messages, it
always issued a conversations.unread_count=0 UPDATE. That UPDATE fired
a postgres_changes event. The page's handleConversationEvent received
it and called setActiveConversation({...prev, ...conv}) — creating a
NEW OBJECT REFERENCE for the conversation prop. That changed the
conversation prop passed to MessageThread → fetchMessages changed →
useEffect re-fired → reset unread → fired another realtime event →
loop forever (visible as ~1-2 refreshes per second).
Fix:
- useEffect now depends on conversation?.id only (a stable string),
not the full object reference.
- onMessagesLoaded is read through a ref so it doesn't need to be
a dependency.
- unread_count reset only runs when the conversation actually has
unread messages (avoids UPDATE-thrashing realtime).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a WhatsApp message arrives, the webhook creates/updates the contact
and conversation fine (so the conversation appears in the Inbox), but
every message INSERT fails silently because the field names don't match
the messages table schema:
Webhook wrote Schema has
----------------- ----------
user_id (doesn't exist)
whatsapp_message_id message_id
direction: 'inbound' sender_type ('customer' | 'agent' | 'bot')
message_type content_type
media_type (doesn't exist)
status: 'received' status ('sending'|'sent'|'delivered'|'read'|'failed')
timestamp created_at
The insert error was caught and logged with console.error but silently
swallowed — the conversation update succeeded (its fields were right),
so the Inbox showed the contact and last message text, but opening the
thread showed zero messages. The 24-hour session timer also read
"no customer messages ever" and immediately marked the session expired.
Fixes in src/app/api/whatsapp/webhook/route.ts:
- processMessage() — message insert now uses the correct schema columns:
sender_type: 'customer'
content_type: message.type
message_id: message.id
status: 'delivered'
created_at: <ISO from Meta timestamp>
- handleStatusUpdate() — lookup uses message_id (not whatsapp_message_id)
and no longer sets updated_at (column doesn't exist on messages).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause of the persistent 'Stored access token is corrupted' error:
handleSave was writing the access_token DIRECTLY to Supabase via the
browser client — WITHOUT calling the server-side POST /api/whatsapp/config
handler. The server handler is what actually encrypts the token with
ENCRYPTION_KEY. So every save stored the token in PLAINTEXT, and every
subsequent decrypt attempt threw 'corrupted'.
This meant no amount of re-saving could ever fix it — each save
re-introduced plaintext data that would fail decryption again.
Changes in src/components/settings/whatsapp-config.tsx:
- handleSave now POSTs to /api/whatsapp/config (server verifies with
Meta AND encrypts the token before storing).
- On page load, after pulling form values from Supabase, we also
call GET /api/whatsapp/config to check the config's health. If the
health check reports needs_reset (decrypt failure), we show a
prominent amber banner with a "Reset Configuration" button.
- Added handleReset that calls DELETE /api/whatsapp/config to clear
the broken row and reset local state.
- Added a "Reset Configuration" button in the main action row
(available whenever a config exists, for manual cleanup).
- Better error messages — shows the actual server-side reason
(token_corrupted, meta_api_error, no_config).
- Save flow now requires re-entering the access token when updating
(can't update without a token to verify against Meta).
Changes in src/app/api/whatsapp/config/route.ts:
- GET now returns 200 with { connected, reason, message, needs_reset }
instead of 500 on decrypt failure. The UI can handle this gracefully.
- Added DELETE handler to remove the user's config row.
- POST also surfaces encryption errors (e.g. missing ENCRYPTION_KEY).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes two bugs in the WhatsApp config API route:
1. Test API Connection button returned 405 Method Not Allowed
The "Test API Connection" button in Settings calls
GET /api/whatsapp/config but only a POST handler existed.
Added a GET handler that:
- Fetches the user's saved config from Supabase
- Decrypts the stored access token
- Pings Meta's /v21.0/{phone_number_id} endpoint to verify
the credentials still work
- Returns { connected: true, phone_info: {...} } on success,
or { connected: false, error: "..." } with a useful message
2. POST handler passed verifyPhoneNumber() args in wrong order
The function signature is verifyPhoneNumber(phoneNumberId, accessToken)
but the POST was calling verifyPhoneNumber(access_token, phone_number_id).
This meant saving a config called Meta API with the token as the URL
and the phone number ID as the auth header — which would either fail
silently or throw an opaque error. Fixed to pass args in the correct
order.
3. While fixing, also improved POST error handling:
- Meta API errors now surface the actual error message to the user
instead of returning a generic 500
- Sets status="connected" and connected_at on successful save
- Uses .maybeSingle() instead of .single() for the existence check
so it doesn't throw on first-time save
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Fixes the cascading warnings:
[useAuth] getSession() timed out after 3s, clearing loading state
[useAuth] getSession() timed out after 3s, clearing loading state
[useAuth] getSession() timed out after 3s, clearing loading state
The warning appeared 3x per page load because 3 components each called
useAuth() independently (DashboardLayout, Sidebar, Header), each firing
their own supabase.auth.getSession() call. The Supabase client has
internal mutex locks around session reads, so concurrent calls from
multiple components queue up and timeout.
Fix: introduce an AuthProvider React Context that calls getSession()
ONCE for the whole subtree. useAuth() now reads from context instead
of making its own call.
Also fixes the Settings page stuck on loading: WhatsAppConfig,
TemplateManager, and TagManager were making their own getSession()
calls in fetch functions. They now read the user from useAuth()
context — no redundant session calls.
Changes:
- src/hooks/use-auth.ts -> use-auth.tsx (JSX for the Provider)
Added AuthProvider component and converted useAuth() to a
context consumer. Renamed because the file now exports JSX.
- src/app/(dashboard)/layout.tsx — wraps children in <AuthProvider>
with a DashboardShell inner component that consumes the context.
- src/components/settings/whatsapp-config.tsx — uses useAuth() for
user instead of calling getSession() itself.
- src/components/settings/template-manager.tsx — same.
- src/components/settings/tag-manager.tsx — same.
- Profile fetch is now fire-and-forget (doesn't block setLoading),
so the UI renders as soon as the session is known.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Major performance improvement. Every client component that needed the
current user was calling supabase.auth.getUser(), which makes a
network round-trip to Supabase to validate the JWT. On a page that
fetches data conditional on the user, the flow was:
Page mount
-> getUser() [network ~150-400ms]
-> query data [network ~150-400ms]
-> render
That's 2 serial round-trips before the page renders.
supabase.auth.getSession() reads the session from localStorage with
NO network call. Using it in client code cuts per-page load by one
full round-trip (~150-400ms depending on region latency).
Replaced in 10 client files:
- src/app/(dashboard)/inbox/page.tsx
- src/app/(dashboard)/pipelines/page.tsx
- src/components/contacts/contact-detail-view.tsx
- src/components/contacts/contact-form.tsx
- src/components/contacts/import-modal.tsx
- src/components/inbox/contact-sidebar.tsx
- src/components/pipelines/deal-form.tsx
- src/components/pipelines/pipeline-settings.tsx
- src/components/settings/tag-manager.tsx
- src/components/settings/template-manager.tsx
- src/components/settings/whatsapp-config.tsx
NOT changed (correctly continue using getUser):
- src/middleware.ts (server-side, needs JWT validation)
- src/app/api/whatsapp/*/route.ts (server-side, security-critical)
- src/hooks/use-auth.ts (PR #4 handles this file)
Security note: getSession() trusts the client-stored session. For
API routes and middleware that make authorization decisions, we still
use getUser() which verifies with Supabase. Client components only
use the session's user ID to filter RLS-protected queries — the RLS
on the database is the actual authorization boundary, so a forged
user ID in the browser wouldn't bypass server-side policies.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Next.js route groups like (dashboard) don't appear in URLs, so the
sidebar, header, and dashboard page were linking to /dashboard/inbox,
/dashboard/contacts, etc. which don't exist and returned 404.
Fixed paths in:
- src/components/layout/sidebar.tsx (nav hrefs)
- src/components/layout/header.tsx (page title map)
- src/app/(dashboard)/dashboard/page.tsx (router.push calls)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>