Commit Graph

13 Commits

Author SHA1 Message Date
Arnas Donauskas
c35f2a1cb4
feat(inbox): add a refresh button to the conversation header
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>
2026-05-22 09:34:39 +03:00
Arnas Donauskas
138ae8d6f4
fix(inbox): replace broken updater-closure flag, add reconnect/visibility resync
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>
2026-05-21 19:30:48 +03:00
Arnas Donauskas
206e110373
fix(inbox): hydrate new conversations from realtime so they surface in the list
Closes #104.

Supabase Realtime payloads only carry the row's own columns — the
`contact:contacts(*)` join the initial fetch uses is absent. A
freshly-inserted conversation arrived in state with `contact: undefined`,
displaying "Unknown" and a blank contact sidebar. When the
message-INSERT for that conversation arrived before the conv-INSERT
event had landed in state, the preview update was a silent no-op and
the conversation appeared stuck on "No messages yet" until something
else triggered a refetch (typically the user sending a reply, which is
why replying made the missing inbound messages "suddenly appear").

Add a deduped `hydrateConversation(id)` helper that fetches the row
with the contact joined and merges it into state. Call it from:
  - conv-INSERT realtime handler — to backfill the contact join
  - message-INSERT handler when the conv isn't in state yet
  - conv-UPDATE handler when the row is missing (self-heal for any
    realtime event ever dropped)

Existing fields in state win on merge: a realtime UPDATE may have
patched last_message_text / unread_count to fresher values than the
single-row read returns, and we don't want to clobber them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:33:32 +03:00
Arnas Donauskas
5d0944e087
feat(inbox): wire conversation assignment dropdown
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>
2026-05-18 15:14:42 +03:00
Arnas Donauskas
e46844d527
chore: add WhatsApp® on first mention across user-facing surfaces
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>
2026-05-06 12:13:26 +03:00
Arnas Donauskas
6cceb81c46
fix(inbox): don't drop messages when ?c= URL param updates
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>
2026-04-20 20:01:59 +03:00
Arnas Donauskas
7612e6c781
feat(mobile): inbox single-pane flow on phone, unchanged on desktop
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>
2026-04-19 11:47:35 +03:00
Arnas Donauskas
03c53905c1
fix(dashboard): open the clicked conversation when deep-linking from dashboard
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>
2026-04-17 21:11:05 +03:00
Arnas Donauskas
5467c3e1e3
fix(inbox): re-clicking active conversation no longer clears messages
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>
2026-04-16 22:11:57 +03:00
Arnas Donauskas
cf706a8c9d
fix: outbound messages silently failing (wrong Meta args + schema)
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>
2026-04-16 21:21:01 +03:00
Arnas Donauskas
976f1ff8ac
fix: inbox refresh loop, wrong connection status, overlapping banner
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>
2026-04-16 21:11:10 +03:00
Arnas Donauskas
2e7d7a6282
perf: use getSession() instead of getUser() in client components
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>
2026-04-16 20:05:00 +03:00
Arnas Donauskas
658d070811
Initial scaffold: WhatsApp CRM with Next.js + Supabase
Complete WhatsApp CRM application built with Next.js 14 (App Router),
Supabase (PostgreSQL + Auth + Realtime), and shadcn/ui.

Features:
- Auth: Login, signup, forgot password with Supabase Auth
- Dashboard: Analytics overview with stats, recent activity
- Inbox: Real-time WhatsApp conversations with message threading
- Contacts: CRUD with tags, custom fields, notes, CSV import
- Broadcasts: 4-step wizard with audience selection, template sending
- Pipelines: Kanban board with deal management
- Settings: WhatsApp API config, templates, tags
- WhatsApp API: Webhook (verify + receive), send, broadcast, media proxy
- Database: 16 tables with RLS policies, indexes, realtime subscriptions
- Dark theme throughout with emerald accents

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:17:31 +03:00