Commit Graph

11 Commits

Author SHA1 Message Date
Arnas Donauskas
14cdbf457b
feat(ui): role-aware gating sweep + account-name in sidebar
PR 9 of the multi-user accounts series — the final piece. Visual
polish so non-owners see consistent disabled states across the
app and so every user always knows which account they're acting in.

Sidebar
- New account-name strip above the user dropdown, gated behind
  the `account_sharing` beta flag (same flag the Members tab
  from PR 6 reads). For solo users (the default) the account is
  named after them, so showing it would just duplicate the user
  name below — flag-gating keeps the original layout untouched
  for single-user installs.
- When the caller's role is admin/agent/viewer (not owner), the
  role is surfaced as a small uppercase chip alongside the
  account name so they have an unambiguous signal of their
  permission level without opening Settings.
- Nav items stay always-visible per the planning doc's
  "show-but-disable" decision — hiding nav rows by role would
  make the app feel broken.

Inbox composer
- Send button + Templates button + textarea are disabled for
  viewers with a "Read-only — viewers can browse but not reply"
  placeholder and tooltip. The existing session-expired branch
  still wins when it applies (session-expired is the more
  specific condition and surfaces template-only sending).

List-page create buttons
- broadcasts/page.tsx: "New Broadcast" CTA (header + empty
  state).
- automations/page.tsx: "Create Automation" CTA.
- flows/page.tsx: "New flow" CTA (header + empty-state).
- contacts/page.tsx: "Add Contact" + "Import" CTAs.
- pipelines/page.tsx: "Add Pipeline" (gates on edit-settings —
  pipelines are settings-class under the 017 RLS) and "Add
  Deal" (gates on send-messages).

All use `useCan(...)` from PR 5. Disabled buttons get a tooltip
explaining why (`title="Read-only — your role can't ..."`).
Solo users see no visual change — single-owner accounts pass
every capability check.

Why show-but-disable, not hide
- A viewer scanning the app should still see what's possible
  in the product, both to understand what they can ask for an
  upgrade for and to avoid the UI feeling silently broken
  ("the New button just disappeared?"). The tooltip carries
  the explanation cheaply.

Verification
- `npm run lint`      → 0 errors (same 19 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 19:02:14 +03:00
Arnas Donauskas
32022c13fe
feat(theme): sweep remaining violet-* → primary across all surfaces
PR #132 (color themes) only tokenized the high-visibility chrome.
Inbox / contacts / pipelines / broadcasts / automations / auth /
settings sub-forms still had ~150 hard-coded `violet-*` classes
across ~32 files, so picking a non-violet theme felt half-broken —
sidebar and header changed colour, but message bubbles, deal cards,
and the broadcast wizard all stayed violet.

This is the mechanical follow-up: replace every `violet-N(/N)?`
with `primary(/N)?` so the theme picker themes the whole app.

Two non-trivial swaps inside the pass:
- `text-white` on a `bg-primary` button → `text-primary-foreground`.
  Emerald and Amber define a *dark* `--primary-foreground` because
  their primaries are bright; `text-white` over those would tank
  contrast.
- `bg-primary hover:bg-primary` (hover identical to base — original
  code had this latent bug from `hover:bg-violet-500`) → adds `/90`
  on hover so the lift is visible.

Test update:
- `broadcast-status.test.ts` asserted shade numbers (`bg-X-N/10`).
  Relaxed the regex to also accept token form (`bg-X/10`) since
  brand-accent status badges now ride the active theme.

Files touched: 49. Lint clean (existing warnings only), 173/173
tests, typecheck + build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:00:32 +03:00
Arnas Donauskas
77dfa4f165
chore: switch accent palette to Hostinger violet, drop emerald green
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>
2026-05-06 11:57:12 +03:00
Arnas Donauskas
8a43cc6e2f
feat(mobile): tiered columns on the broadcasts table
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>
2026-04-19 12:51:49 +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
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
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
296a905976
feat(broadcasts) C: custom-field audience, exclude tags, live preview
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>
2026-04-17 11:39:24 +03:00
Arnas Donauskas
244d04ca93
feat(broadcasts) B: list rates, detail funnel + CSV, save-as-draft
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>
2026-04-17 11:31:13 +03:00
Arnas Donauskas
5d5c5f0a20
fix(broadcasts): per-recipient variables, webhook status sync, rate limit
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>
2026-04-17 11:22:38 +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