Commit Graph

594 Commits

Author SHA1 Message Date
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
73ae399a4d
feat(mobile): app shell drawer + responsive header
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>
2026-04-19 11:39:08 +03:00
Arnas Donauskas
f223d5b86f
Merge pull request #47 from ArnasDon/feat/seo-pass
feat(seo): full SEO pass — metadata, structured data, sitemap, robots
2026-04-19 11:24:50 +03:00
Arnas Donauskas
c2587f7466
feat(seo): full SEO pass — metadata, structured data, sitemap, robots
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>
2026-04-19 11:17:48 +03:00
Arnas Donauskas
ba08d0b45f
Merge pull request #46 from ArnasDon/feat/marketing-landing-page
feat(marketing): full public landing page at /
2026-04-19 02:35:08 +03:00
Arnas Donauskas
d5d406fe8e
feat(marketing): full public landing page at /
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>
2026-04-18 14:51:30 +03:00
Arnas Donauskas
221afcefc4
Merge pull request #45 from ArnasDon/fix/dashboard-chart-hover-precision
fix(dashboard): precise hover on the conversations-over-time chart
2026-04-17 23:24:43 +03:00
Arnas Donauskas
2aa3c50878
fix(dashboard): precise hover on the conversations-over-time chart
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>
2026-04-17 23:19:13 +03:00
Arnas Donauskas
eb8d0b3ac6
Merge pull request #44 from ArnasDon/fix/dashboard-activity-feed-pagination
fix(dashboard): paginate recent activity feed with a size picker
2026-04-17 22:39:18 +03:00
Arnas Donauskas
b1eb02f48e
Merge pull request #43 from ArnasDon/fix/dashboard-pipeline-card-height
fix(dashboard): pipeline card stretches to match the chart row height
2026-04-17 22:37:26 +03:00
Arnas Donauskas
96debd193b
fix(dashboard): paginate recent activity feed with a size picker
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>
2026-04-17 22:35:42 +03:00
Arnas Donauskas
2954123d9b
fix(dashboard): pipeline card stretches to match the chart row height
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>
2026-04-17 22:29:54 +03:00
Arnas Donauskas
3e02098579
Merge pull request #42 from ArnasDon/feat/dashboard-analytics
feat(dashboard): replace placeholder with real analytics
2026-04-17 22:20:50 +03:00
Arnas Donauskas
ed669424b6
feat(dashboard): replace placeholder with real analytics
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>
2026-04-17 22:16:00 +03:00
Arnas Donauskas
5be2bad51d
Merge pull request #41 from ArnasDon/fix/automations-validate-step-config-on-activate
fix(automations): reject activation of invalid step/trigger config
2026-04-17 21:58:46 +03:00
Arnas Donauskas
348dbb7d6c
Merge pull request #40 from ArnasDon/fix/automations-atomic-execution-counter
fix(automations): atomic execution_count via SQL function (migration 007)
2026-04-17 21:56:37 +03:00
Arnas Donauskas
98751b8fe6
Merge pull request #39 from ArnasDon/fix/automations-welcome-template-uses-first-inbound
fix(automations): Welcome Message template uses first_inbound_message
2026-04-17 21:54:28 +03:00
Arnas Donauskas
3758711f5a
Merge pull request #38 from ArnasDon/fix/automations-meta-send-contact-ownership
fix(automations): scope meta-send contact lookup by user_id
2026-04-17 21:53:05 +03:00
Arnas Donauskas
53bb056803
Merge pull request #37 from ArnasDon/fix/automations-template-param-ordering
fix(automations): order send_template params numerically, not lexicographically
2026-04-17 21:51:36 +03:00
Arnas Donauskas
570fc1234c
fix(automations): reject activation of invalid step/trigger config
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>
2026-04-17 21:50:13 +03:00
Arnas Donauskas
635b5c1df6
fix(automations): atomic execution_count via SQL function (migration 007)
The engine did a client-side read-modify-write on
automations.execution_count, so two concurrent dispatches of the
same automation (e.g. fired for two different contacts within the
same second) could both read N and both write N+1, permanently
losing one count.

- Migration 007 adds increment_automation_execution_count(p_automation_id)
  as a SECURITY DEFINER sql function. Only service_role can EXECUTE
  it — an authenticated user can't juice someone else's counter by
  calling the RPC directly.
- Engine now calls this RPC instead of reading automation.execution_count
  and adding 1. Atomic in one statement; concurrent dispatches both
  land correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:45:27 +03:00
Arnas Donauskas
a3d59d8ea3
fix(automations): Welcome Message template uses first_inbound_message
The template seeded trigger_type as new_contact_created, which only
fires when the webhook auto-creates a contact row. A user who added
or imported the contact manually before the contact first messaged
would not hit the welcome flow — that's almost certainly not what the
user setting up "Welcome Message" wants.

first_inbound_message (added in PR #33) is the superset: fires the
first time any contact — auto-created or pre-existing — sends us a
customer message. Correct semantic for a welcome/greeting template.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:42:20 +03:00
Arnas Donauskas
4afa9bea32
fix(automations): scope meta-send contact lookup by user_id
The engine runs with the service-role client (bypassing RLS) and the
public POST /api/automations/engine endpoint accepts contact_id from
the request body. Without a user_id filter on the contacts lookup,
an authenticated user could fire their own automations against
another tenant's contact UUID and send via their own WhatsApp config
to that contact's phone. UUIDs are unguessable so practical risk is
low, but this is cheap defense-in-depth.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:39:33 +03:00
Arnas Donauskas
f25f20ed63
fix(automations): order send_template params numerically, not lexicographically
Meta's template placeholders are positional ({{1}}, {{2}}, …). We
previously sorted the keys of step_config.variables lexicographically,
so a template with ≥10 variables got params in the order
"1", "10", "2", …, silently scrambling every render past the 9th
placeholder. Fix: sort numeric-looking keys by numeric value, fall back
to locale compare for non-numeric keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:36:40 +03:00
Arnas Donauskas
59d0ea6e17
Merge pull request #36 from ArnasDon/fix/inbox-unread-indicator
fix(inbox): reliable unread reset + sidebar green-dot indicator
2026-04-17 21:23:22 +03:00
Arnas Donauskas
5943c59193
Merge pull request #35 from ArnasDon/fix/dashboard-deep-link-to-conversation
fix(dashboard): open the clicked conversation when deep-linking from dashboard
2026-04-17 21:20:57 +03:00
Arnas Donauskas
bd62322da4
Merge pull request #34 from ArnasDon/fix/builder-dropdown-width
fix(automations): widen step-picker dropdown so labels don't wrap
2026-04-17 21:19:04 +03:00
Arnas Donauskas
c2ef80c7df
fix(inbox): reliable unread reset + sidebar green-dot indicator
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>
2026-04-17 21:17:28 +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
ad7137ece8
fix(automations): widen step-picker dropdown so labels don't wrap
The add-step dropdown was inheriting its width from the "+" button
(≈32px), hitting the base Menu min-w-32 (128px) and wrapping every
multi-word label ("Send Message", "Update Contact Field", etc.).
Set min-w-56 and explicit align="start" so the list anchors to the
button's left edge and each label fits on one line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 21:04:20 +03:00
Arnas Donauskas
a5f2a40b37
Merge pull request #33 from ArnasDon/feat/first-inbound-message-trigger
feat(automations): add 'First Message from Contact' trigger
2026-04-17 20:51:50 +03:00
Arnas Donauskas
5243188d0c
feat(automations): add 'First Message from Contact' trigger
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>
2026-04-17 20:47:13 +03:00
Arnas Donauskas
da809ced5f
Merge pull request #32 from ArnasDon/fix/automations-actually-send-via-meta
fix(automations): actually send messages via Meta
2026-04-17 20:37:13 +03:00
Arnas Donauskas
0e75eb8d11
fix(automations): actually send messages via Meta
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>
2026-04-17 20:34:52 +03:00
Arnas Donauskas
e8c952b129
Merge pull request #31 from ArnasDon/feat/automations
feat(automations): visual flow builder, engine, and logs viewer
2026-04-17 20:10:02 +03:00
Arnas Donauskas
b7b362ae42
feat(automations): visual flow builder, engine, and logs viewer
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>
2026-04-17 19:39:02 +03:00
Arnas Donauskas
8e61aa5302
Merge pull request #30 from ArnasDon/fix/broadcast-hardening-review
fix(broadcasts): senior-review hardening pass
2026-04-17 17:45:30 +03:00
Arnas Donauskas
66dd4ef97e
fix(broadcasts): senior-review hardening pass
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>
2026-04-17 17:38:52 +03:00
Arnas Donauskas
a328ef1f19
Merge pull request #29 from ArnasDon/feat/broadcasts-delete-and-meta-template-sync
feat(broadcasts): delete campaign + sync templates from Meta
2026-04-17 17:10:47 +03:00
Arnas Donauskas
b37229f6a9
feat(broadcasts): delete campaign + sync templates from Meta
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>
2026-04-17 17:08:33 +03:00
Arnas Donauskas
6bae86bd66
Merge pull request #28 from ArnasDon/fix/contact-deletion-fk-constraints
fix(contacts): allow deletion — SET NULL on broadcast_recipients + deals FKs
2026-04-17 16:58:42 +03:00
Arnas Donauskas
5f488e129a
fix(contacts): allow deletion — SET NULL on broadcast_recipients, deals
Root cause (error 23503):
  broadcast_recipients.contact_id and deals.contact_id were declared
  NOT NULL REFERENCES contacts(id) with no ON DELETE clause, so
  Postgres defaulted to NO ACTION. Any contact who'd ever received a
  broadcast or been attached to a deal became un-deletable with
  "violates foreign key constraint broadcast_recipients_contact_id_fkey".

Fix (migration 004):
  Relax both FKs to ON DELETE SET NULL + drop NOT NULL on both
  columns. History rows survive with contact_id = NULL. The UI is
  already null-safe:
    - broadcast detail:  contact?.name ?? 'Unknown'
    - deal card:         contact?.name || contact?.phone || 'No contact'
    - CSV export:        contact?.name ?? ''

Why not CASCADE:
  CASCADE would wipe broadcast recipient rows and trigger the
  aggregate recount on broadcasts (migration 003), retroactively
  changing sent_count / delivered_count / read_count. It would also
  delete deal history. SET NULL preserves both counts and history.

Type/code:
  - Deal.contact_id + BroadcastRecipient.contact_id widen to
    string | null
  - DealForm: setContactId(deal.contact_id ?? "") — the contact
    picker uses "" to mean unselected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 16:56:45 +03:00
Arnas Donauskas
5c2f87fd01
Merge pull request #27 from ArnasDon/fix/favicon-and-template-language
fix: WaCRM favicon + template language defaults to en_US
2026-04-17 16:04:38 +03:00
Arnas Donauskas
980ce6aa25
fix: WaCRM favicon + template language defaults to en_US
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>
2026-04-17 16:03:02 +03:00
Arnas Donauskas
f8543270d1
Merge pull request #26 from ArnasDon/fix/cache-control-headers
fix(deploy): Cache-Control headers so CDN stops serving stale HTML
2026-04-17 14:36:39 +03:00
Arnas Donauskas
56c76af739
fix(deploy): Cache-Control headers so CDN stops serving stale HTML
Root cause: Hostinger's CDN was applying s-maxage=31536000 (1 year)
to prerendered HTML pages by default. Each deploy produced fresh
Turbopack chunk hashes, but the edge kept serving year-old HTML that
referenced chunk filenames the new build no longer had on disk —
resulting in HTML 200 + every /_next/static/*.{js,css} 404, page
rendered unstyled. Private/incognito didn't help because the cache
is at the edge, not the browser.

Fix: explicit Cache-Control via next.config.ts headers():
- /_next/static/*  — public, max-age=31536000, immutable
  Content-hashed filenames; safe forever.
- /api/*           — no-store
  Per-user, must never be shared at the edge.
- everything else  — public, max-age=0, s-maxage=300,
                     stale-while-revalidate=86400
  Edge serves instantly from cache for 5 min, then keeps serving
  cached content while refreshing in the background for up to 24h.
  Chunk-hash drift after a deploy self-heals within ~5 min.

Verification after deploy:
  curl -I https://<host>/login | grep -i cache-control
  Expect: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
  (If Hostinger overwrites this, fall back to configuring
  purge-on-deploy at the platform layer.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 14:34:46 +03:00
Arnas Donauskas
ed8dd6de84
Merge pull request #25 from ArnasDon/fix/broadcasts-promote-b-c-and-send
fix(broadcasts): promote B+C + attach user_id on send
2026-04-17 13:57:30 +03:00
Arnas Donauskas
cd04e0682c
fix(broadcasts): attach user_id on send, surface errors with toast
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>
2026-04-17 13:55:18 +03:00
Arnas Donauskas
5dbb26e4fc
Merge pull request #23 from ArnasDon/feat/broadcasts-audience-c
feat(broadcasts) C: custom-field audience, exclude tags, live preview
2026-04-17 11:44:49 +03:00
Arnas Donauskas
db6e853faf
Merge pull request #21 from ArnasDon/fix/broadcasts-correctness-a
fix(broadcasts) A: per-recipient vars, webhook status sync, rate limit
2026-04-17 11:41:26 +03:00