Commit Graph

9 Commits

Author SHA1 Message Date
Arnas Donauskas
312683ca05
feat(inbox): send photo, video, document & voice in chat (#213)
Agents can now attach a photo/video/document or record a voice note in
the inbox composer and send it live in a 1:1 thread — previously media
could only be received from customers or sent via the Flows builder.

- 023_chat_media.sql: new account-scoped `chat-media` Storage bucket
  (mirrors flow-media RLS from 020) plus Meta-accepted audio MIME types.
- meta-api: sendMediaMessage gains an `audio` kind (link-only — Meta
  rejects caption/filename on audio; WhatsApp renders OGG/Opus as a
  voice note).
- /api/whatsapp/send: media branch reusing the phone-variant retry loop.
- /api/whatsapp/transcode-audio: Node-runtime route remuxing the
  WebM/Opus that Chromium's MediaRecorder produces to OGG/Opus via
  ffmpeg-static (Firefox/Safari record accepted formats and skip it).
- composer: attach menu, file pickers, voice recorder + auto-transcode,
  preview with caption; thread: optimistic handleSendMedia.
- shared uploadAccountMedia helper (flows form refactored onto it).
- next.config: allow microphone=(self) (was fully blocked) + CSP
  media-src; document optional FFMPEG_PATH override.

Tests: meta-api audio payload shape, buildMediaPath, audio-transcode
helpers (14 new). typecheck/lint/build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 10:07:16 +03:00
Arnas Donauskas
4aa329b527
feat(theme): add light/dark mode toggle (#243)
Introduce a light/dark MODE axis orthogonal to the existing accent
themes. Neutral surface tokens now live in html[data-mode="light|dark"]
blocks; accent tokens (--primary*, --ring, --chart-1, --sidebar-primary*)
stay in html[data-theme] blocks, so any accent works in either mode.

- globals.css: split neutrals (mode) from accent (theme); add light
  palette; dark values unchanged (dark stays the default).
- lib/themes.ts: Mode type, DEFAULT_MODE=dark, MODE_STORAGE_KEY, isMode.
- use-theme.tsx: provider owns mode/setMode/toggleMode + cross-tab sync.
- layout.tsx: boot script applies persisted mode before first paint
  (no flash); colorScheme "dark light".
- mode-toggle.tsx (header sun/moon) + themed-toaster.tsx (mode-aware).
- appearance-panel.tsx: light/dark control + updated copy.
- Tokenize hardcoded neutral colors (slate/white/gray) -> semantic
  tokens across the app so every surface flips with mode. Flow canvas
  and message-bubble timestamps made mode-aware too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 17:29:46 +03:00
Arnas Donauskas
cbcad71fda
feat(ui): front-end correctness pass from multi-user review (S1,S2,S6-S8,P8,P9)
PR C of the post-review fix series. Bundles six related front-end
correctness / portability fixes. No behavior change for happy-path
users — these are belt-and-braces against edge cases.

S1 — useCan exhaustiveness (`src/hooks/use-can.ts`)
- Add a `default` branch with `const _: never = action` so adding
  a new `CanAction` without a case fails the typecheck instead of
  silently returning `undefined` at runtime.

S2 + P8 — GatedButton helper (`src/components/ui/gated-button.tsx`)
- `<Button disabled title="...">` doesn't render a tooltip in
  Safari or older Firefox: those browsers don't fire mouse events
  on disabled controls, so the title never surfaces. Viewers on
  those browsers saw silently-disabled CTAs with no explanation.
- New `<GatedButton canAct={...} gateReason="...">` wraps the
  Button in a non-disabled `<span>` that owns the `title`. The
  span receives mouseover everywhere; the tooltip works.
- Also subsumes the duplicated `READ_ONLY_TITLE = "..."` constants
  across 5 list pages. `gateReason` is the verb phrase that
  completes "Read-only — your role can't <gateReason>".
- Migrated call sites: broadcasts / automations / flows / contacts
  / pipelines list pages + inbox composer (Send + Templates
  buttons). The composer's textarea keeps its inline title since
  the GatedButton pattern only applies to buttons.

S6 — Revert optimistic role-change on error (`members-tab.tsx`)
- Pre-fix: PATCH failure showed a toast and left the local
  member-row state with the new role. Next interaction
  (re-toggling to the same role) was a no-op via the
  `member.role === nextRole` guard at the top, so the row
  appeared stuck. Now we capture `previousRole` before the
  optimistic flip and revert on both 4xx and network failure.

S7 — Sidebar flag check matches settings (`sidebar.tsx`)
- Pre-fix: sidebar's `accountSharingEnabled` didn't guard on
  `profileLoading`, so the account-name strip briefly was hidden
  during the initial profile fetch then popped in. Settings page
  did this guard correctly. Now they match.

S8 — Referrer-Policy: no-referrer on /join (`join/layout.tsx`)
- The plaintext invite token lives in the URL path. Belt-and-
  braces against any future external resource on the join page
  leaking the token via the Referer header. Set via Next 16's
  `metadata` export → `<meta name="referrer" content="no-referrer">`.
  Also marks the route `noindex, nofollow` so a join URL can't end
  up in search results.

P9 — Client-side label length validation (`invite-member-dialog.tsx`)
- Server caps `label` at 80 chars and returns 400 on overflow.
  Add a `MAX_LABEL_LEN` constant on the client, point both the
  `<Input maxLength>` and the pre-submit check at it. Catches the
  paste-then-submit path where a paste lands an over-limit string
  in state before the keystroke-level cap engages.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 19:33:28 +03:00
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
339b1fe94f
feat(inbox): reactions, reply-to, and copy on chat messages
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>
2026-05-19 16:17:26 +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
a90ae1d991
fix(inbox): align composer send and template buttons with the textarea
The \"Type '/' for quick replies\" hint used to live inside the
textarea's flex-1 column. With items-end on the parent row, the
column's height grew by the hint's line box and the buttons aligned
to *that* bottom — sitting one row below the textarea on screen.

Move the hint out to a sibling of the flex row. The row now only
measures the textarea, items-end aligns the buttons level with the
textarea's bottom, and the hint drops neatly below with a pl-11
indent so it stays aligned under the textarea's left edge
(w-9 button + gap-2 = 44px).

Verified structurally via source; the /inbox route itself requires
auth so the preview couldn't exercise the rendered UI. Typecheck
clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:35:06 +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