Commit Graph

93 Commits

Author SHA1 Message Date
Arnas Donauskas
11475840e1
feat(templates): media headers + URL/copy-code button params on send
Closes the last hole left by PR 3 — until now, sendTemplateMessage
only emitted a body component, so templates with image/video/
document headers or URL-with-{{1}} buttons failed at send time
with Meta's "missing component" 400. After this PR, every
APPROVED template that wacrm can submit is also fully sendable.

New helper
----------

src/lib/whatsapp/template-send-builder.ts builds the per-send
Meta components array from a MessageTemplate row + caller-supplied
SendTimeParams. It's a different beast from template-components.ts
(which builds the SUBMISSION shape) because the send-time payload
has a different schema: header type-specific parameter objects,
button components with sub_type + index, body_text without a 2D
example wrapper, etc.

Auto-fills as much as possible from the template:

  * static IMAGE/VIDEO/DOCUMENT headers ride along using the row's
    header_media_url (or header_handle when present — preferred)
  * URL buttons without {{1}} need no per-send payload
  * QUICK_REPLY / PHONE_NUMBER buttons need nothing
  * COPY_CODE buttons fall back to the template's example so the
    most common "static promo code" case Just Works without UI

Variable-bearing fields require per-send values:

  * TEXT header with {{1}}        → messageParams.headerText
  * Body variables                 → messageParams.body[]
  * URL button with {{1}}          → messageParams.buttonParams[i]
  * COPY_CODE override             → messageParams.buttonParams[i]

19 unit tests assert the wire shape per scenario — header types,
URL button at correct index when QR buttons precede it, example
fallback, and the throw-with-specific-message paths.

sendTemplateMessage extension
-----------------------------

src/lib/whatsapp/meta-api.ts gains optional `template` +
`messageParams` args. When `template` is provided, the full
components array is built via the new helper. Legacy `params:
string[]` (body only) path is preserved as-is — old callers
behave identically. When both shapes are present, structured
messageParams.body wins.

Route + UI wiring
-----------------

POST /api/whatsapp/send and /api/whatsapp/broadcast now load the
template row by (user_id, name, language) and pass it through.
Broadcast loads once per request (not per recipient — avoids N+1).
Both accept `template_message_params` / per-recipient
`messageParams` from the client.

src/components/inbox/template-picker.tsx scans the selected
template for variable-bearing fields and renders an input per
need: header text variable, body variables (existing), URL
button suffixes. Per-button preview shows the final URL with
the substitution so the user sees what the recipient will see.
The callback shape changes to `(template, { body, headerText?,
buttonParams? })`; message-thread.tsx updated accordingly.

Out of scope
------------

Broadcast composer doesn't yet collect per-recipient header /
button params — the legacy body-only flow continues to work for
broadcasts. Templates with header / URL-button variables broadcast
fine if every recipient gets the same value via API call shape;
the UI for varying it per recipient lands in a follow-up.

This is the last PR of the Issue #147 overhaul sequence.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 16:38:15 +02:00
Arnas Donauskas
a2100a3076
feat(templates): edit / resubmit / delete lifecycle endpoints
Adds the missing lifecycle operations so users can manage existing
templates without leaving wacrm.

API surface
-----------

PATCH /api/whatsapp/templates/[id]
  Edits (and re-submits) an existing Meta-side template. Used by
  the "Edit" action on APPROVED rows and "Resubmit" on REJECTED /
  PAUSED rows — same operation, different UI labels. Meta replaces
  components wholesale and flips status back to PENDING for
  re-review. Status guard rejects edits on PENDING / DISABLED /
  IN_APPEAL / PENDING_DELETION and on local-only (no
  meta_template_id) rows. Honors WHATSAPP_TEMPLATES_DRY_RUN.

DELETE /api/whatsapp/templates/[id]
  Calls Meta `DELETE /{waba_id}/message_templates?name=X&hsm_id=Y`
  when meta_template_id is set — hsm_id is critical, without it
  Meta deletes EVERY language variant of the same name. Treats
  Meta 404 as a no-op (template already gone). Always removes the
  local row.

New Meta helpers
----------------

src/lib/whatsapp/meta-api.ts gains editMessageTemplate and
deleteMessageTemplate following the established named-args +
throwMetaError pattern. Eleven new fetch-mock tests assert the
exact wire shape — especially that hsm_id always rides along when
provided, which is the difference between "delete one variant"
and "delete every variant."

UI changes
----------

src/components/settings/template-manager.tsx:
  * Edit (pencil) button visible for APPROVED rows
  * Resubmit (rotate) button visible for REJECTED / PAUSED rows
    — same flow as Edit, different label
  * Delete now calls the route handler (so Meta gets the call too)
    with a spinner during the in-flight request
  * Dialog pre-fills form from the row when opened in edit mode
  * Dialog title / CTA / description switch between "New Message
    Template" + "Submit for Approval" and "Edit Message Template"
    + "Save & Resubmit"
  * Name + language inputs are disabled in edit mode (Meta keys
    templates by id; changing either is effectively a new template)
  * Closing the dialog clears editingId so the next open starts
    fresh

Design decision
---------------

One PATCH endpoint instead of the two endpoints the plan called
for (PATCH for edit, POST for resubmit). The Meta call is the same
operation either way — the difference is UI semantics, handled in
the component. Cuts duplication; easy to split later if needed.

Out of scope (final PR remaining in the sequence):
  * Webhook handler for real-time status (PR 5)
  * sendTemplateMessage media + button param support (PR 6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:57:24 +02:00
Arnas Donauskas
3113738c7e
feat(templates): submit-to-Meta endpoint + component-based form builder
Closes the second half of #147. Users can now create a template in
wacrm and submit it to Meta for approval without leaving the app —
no more dummy local rows that fail with Meta error #132001 on send.

API surface
-----------

POST /api/whatsapp/templates/submit
  Auth → fetch whatsapp_config → validate payload → build Meta
  components → POST to /{waba_id}/message_templates → persist row
  with meta_template_id + status + sample_values +
  last_submitted_at. Per-field validators throw with specific
  messages before the network call, so misconfigured templates
  fail with "button #3 missing url" instead of a 400 from Meta.
  429s are detected and surfaced as "rate limit hit (100/hr)".

WHATSAPP_TEMPLATES_DRY_RUN=true short-circuits the Meta call and
issues a synthetic `dry-run-<uuid>` meta_template_id so CI and
local dev can exercise the full form without a real WABA.

New modules
-----------

src/lib/whatsapp/template-validators.ts
  Pure functions encoding Meta's caps: 1024-char body, 60-char
  footer/header, 25-char button text, 10 buttons max, 2 URL / 1
  PHONE_NUMBER / 1 COPY_CODE, QUICK_REPLY contiguity rule, URL
  example required when {{1}} is present, COPY_CODE example
  required, header media URL must be http(s), variables must be
  contiguous starting at {{1}}. 41 unit tests cover each rule.

src/lib/whatsapp/template-components.ts
  Translates our local row shape into Meta's components array
  (HEADER → BODY → FOOTER → BUTTONS, body_text examples as a 2D
  array per spec, header_handle preferred over header_url when
  both are present). Snapshot tests assert the wire shape.

src/lib/whatsapp/template-status-normalize.ts
  Single source of truth for upstream→local status mapping.
  Replaces the duplicated normalizer in sync/route.ts; the
  upcoming webhook handler will share it too.

src/lib/whatsapp/meta-api.ts
  New submitMessageTemplate() helper following the existing
  named-args + throwMetaError pattern.

Form rewrite
------------

src/components/settings/template-manager.tsx replaces the flat
form with a component-based builder:

  * Header: format select (None / Text / Image / Video / Document)
    + conditional inputs — text + variable-aware sample row for
    TEXT, URL paste for media (file upload slot ready for the
    Resumable Upload follow-up)
  * Body: textarea with live {{N}} extraction → sample-value rows
    auto-appear and shrink as variables are added/removed
  * Footer: 60-char capped input
  * Buttons editor: add up to 10, per-row type picker
    (Quick Reply / URL / Phone / Copy Code), conditional URL +
    example + phone + code fields, X to remove
  * Card display now surfaces rejection_reason / submission_error
    (red banner) and quality_score (GREEN/YELLOW/RED chip)
  * AUTHENTICATION shows a "create in Meta Manager + Sync" banner
    and disables Submit — that category has a different shape
    (fixed body, mandatory OTP button) and lands in a follow-up

Side effects
------------

  * Broadcast Step 1 picker (step1-choose-template.tsx) now
    filters to status='APPROVED' to match the inbox picker —
    selecting a non-approved template would 400 on send.
  * .env.local.example documents META_APP_ID (Resumable Upload
    follow-up) and WHATSAPP_TEMPLATES_DRY_RUN.

Out of scope (subsequent PRs):
  * Edit / resubmit / delete endpoints (PR 4)
  * Webhook handler for real-time status (PR 5)
  * sendTemplateMessage media + button param support (PR 6)
  * Resumable Upload + AUTHENTICATION builder (follow-up)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 15:34:58 +02:00
Arnas Donauskas
aa4f34c94c
feat(templates): store raw Meta status enum + lay schema groundwork for submission
Switches message_templates.status from the local TitleCase set
(Draft|Pending|Approved|Rejected) to Meta's raw enum (DRAFT|PENDING|
APPROVED|REJECTED|PAUSED|DISABLED|IN_APPEAL|PENDING_DELETION) and
adds the columns the upcoming submit / edit / resubmit / webhook
flows need. No new user-facing behavior — this is groundwork.

Migration 014 also adds:

  * sample_values JSONB        Meta requires these for variable templates
  * meta_template_id TEXT      hsm_id for scoped edit/delete (avoids
                               nuking sibling-language variants)
  * rejection_reason TEXT      surfaced from webhook on REJECTED
  * quality_score TEXT         GREEN | YELLOW | RED, from webhook
  * header_handle TEXT         from Resumable Upload (media headers)
  * header_media_url TEXT      URL fallback for media headers (v1 path)
  * submission_error TEXT      last 4xx from Meta, for retry surface
  * last_submitted_at          rate-limit awareness (100 creates/hour)
  * buttons JSONB shape CHECK  permissive array-of-typed-objects guard
                               (strict per-type validation lives in API)
  * UNIQUE (user_id, name, language)
                               so sync upsert is atomic and prevents
                               duplicate language variants
  * idx on meta_template_id    fast webhook lookup

Sync route now:
  * persists Meta's status verbatim (PAUSED no longer collapses to
    REJECTED — these are different states with different recoverability)
  * parses BUTTONS components into the new discriminated-union shape
  * extracts sample_values from body.example.body_text + header.example
  * persists meta_template_id, quality_score, header_handle

Shared template-status.ts gives the manager / inbox picker / broadcast
picker one source of truth for labels + dark-theme badge classes.

Part of the Issue #147 overhaul plan (PR 2 of 6 in the sequence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 12:12:01 +02:00
Arnas Donauskas
266e487ac8
fix(templates): map "None" header sentinel to null before save
The header type Select uses value="none" as a sentinel (Radix disallows
value=""), but the Save handler passed it through verbatim to Supabase.
The DB CHECK constraint on message_templates.header_type only allows
text|image|video|document, so picking "None" raised a constraint
violation and the template never saved.

Normalize "none" (and any falsy value) to '' at the input boundary so
form.header_type is always either '' (→ null on save) or a valid type.

Closes #147 (the header-dropdown half — Meta submission is tracked separately).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 11:45:59 +02:00
Arnas Donauskas
4e89b2b902
feat(flows): soft-GA — open to all users + Beta label
The Flows feature was previously gated on a per-account
`profiles.beta_features` opt-in: the sidebar entry was invisible
to non-beta accounts, every /api/flows route 404'd, the webhook
dispatcher was a no-op, and the agent-handoff pause was skipped.

This PR opens the surface to every authenticated user. A small
"Beta" chip in the sidebar nav + on the /flows page H1 keeps the
"young feature" signal without holding anyone out.

Removed:
- `src/lib/flows/feature-flag.ts` + its tests (-11 tests).
- The `requireFlowsBeta` / `requireFlowsBetaAndOwnership` helpers
  in the API routes — collapsed to plain `requireUser` / RLS
  ownership.
- The `isProfileFlowsEnabled` helper + branch in the webhook
  dispatcher — the runner takes the cheap "no_match" exit on
  accounts with no active flows (single indexed SELECT).
- The agent-handoff pause gate in /api/whatsapp/send — the UPDATE
  matches zero rows when no active run exists, also cheap.
- Client-side beta gates in the 3 flows pages (and the redundant
  `useAuth` + `profileLoading` plumbing they needed).
- The defensive message-bubble gate that would have rendered
  interactive replies as plain text for non-beta accounts.
- The `betaKey` field on the sidebar NavItem schema, replaced by a
  simpler boolean `beta` that purely drives the visual chip.

Kept:
- `profiles.beta_features` column. Generic mechanism for future
  beta gates; no current feature reads it. Comments in
  `src/types/index.ts` + `use-auth.tsx` updated to say so.
- The chip can be removed in a one-liner once the team's satisfied
  Flows has bedded in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 17:16:58 +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
54a969c56d
feat(settings): color theme picker (5 themes)
Adds a new "Appearance" tab in Settings with 5 dark color themes
(Violet, Emerald, Cobalt, Amber, Rose). Pick a theme → applied
immediately and persisted to localStorage. Boot script in the root
layout replays the pick before first paint so there's no
flash-of-default on subsequent loads.

Implementation, ordered the way it's read:

1. `globals.css` — 5 `html[data-theme="..."]` blocks extracted
   verbatim from the design handoff at project/Color Themes.html.
   `:root` mirrors Violet so SSR / first-paint renders correctly
   even before the boot script runs. Three new tokens
   (`--primary-hover`, `--primary-soft`, `--primary-soft-2`) added
   to all themes — used by tinted-primary surfaces.

2. `src/lib/themes.ts` — single source of truth for the catalog
   (id, name, tagline, swatch). Adding a theme = append a CSS
   block + append an entry here.

3. `src/hooks/use-theme.tsx` — ThemeProvider client component. Reads
   what the boot script already applied, syncs across tabs via the
   `storage` event, persists to localStorage. Falls back to a no-op
   setter when used outside the provider.

4. `src/app/layout.tsx` — inline `beforeInteractive` script that
   reads localStorage and sets `documentElement.dataset.theme`
   before React hydrates. Knowledge of valid theme IDs comes from
   the THEME_IDS constant so adding a theme doesn't silently break
   the boot path. Wraps the tree in ThemeProvider.

5. `src/components/settings/appearance-panel.tsx` — picker UI. 5
   cards, swatch + name + tagline + a 4-tone preview strip. Active
   card carries a primary-tinted ring + "Active" chip.

6. `src/app/(dashboard)/settings/page.tsx` — new "appearance" tab
   alongside profile/whatsapp/templates/tags.

7. Key-surface tokenization — replaced hard-coded `violet-*` with
   `primary` tokens on the high-visibility surfaces so the theme
   switch feels real: sidebar logo + active nav + unread dot +
   avatar fallback, header avatar, dashboard-shell loading
   spinner, Flows header icon + entry-node picker icon + entry
   badge + flash ring, Flows list page (template card hover,
   FlowCard icon), Flows editor + runs "back" links, Settings tabs
   active state.

NOT touched (intentional follow-ups):
- Per-node-type colors in flow-builder.tsx NODE_META — those are a
  deliberate per-type rainbow, not a brand accent.
- The inbox/contacts/pipelines surfaces — many `violet-*` usages
  there. Sweep is a contained follow-up; the user explicitly
  scoped this PR to "tokens + tab + key surfaces".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:48:40 +03:00
Arnas Donauskas
47836d2c08
fix(flows): pin validation panel to viewport bottom while editing
"No issues. Ready to activate." (and the equivalent error count)
sat at the very bottom of the editor. With even a handful of nodes
the panel was below the fold — the user had to scroll past every
node card to see whether they could activate.

Wrap the panel in a sticky-bottom container so the activate-readiness
status follows the user as they scroll. Shadow-xl gives it the lift
to read as "pinned" against the node list behind it.

Side fix while I was in there: the success-state panel used
bg-emerald-500/10 (10% emerald on whatever's behind). Fine when
nothing's behind, but sticky-positioned over a slate-900 node card
the green tint bled through ugly. Switched both states to a
bg-slate-950 base with the severity expressed in the border + text
color only — readable on any backdrop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:19:19 +03:00
Arnas Donauskas
31bd967861
Merge remote-tracking branch 'origin/main' into feat/flows-unsaved-changes-guard
# Conflicts:
#	src/components/flows/flow-builder.tsx
2026-05-22 15:16:35 +03:00
Arnas Donauskas
323e0f06cb
Merge pull request #121 from ArnasDon/feat/flows-clickable-validation-issues
feat(flows): clickable validation issues jump to node
2026-05-22 15:10:30 +03:00
Arnas Donauskas
ad91d205fb
feat(flows): unsaved-changes indicator + reload guard
Editing 5 node configs and then refreshing the tab silently wiped them.
Activate also silently invoked Save first — useful, but invisible: users
had no way to know if pending edits had landed.

Adds:
- A `dirty` boolean flipped true on any user edit, false on a successful
  PUT. Internal `state.status` changes from the activate API path use
  raw setState and don't dirty the form.
- An "● Edited" amber chip next to the StatusBadge while dirty.
- A `beforeunload` listener that prompts the native confirm on
  reload / tab-close / external-link click when dirty.

SPA navigation (sidebar, back button) isn't covered — Next 16's App
Router doesn't fire beforeunload on client-side routes. Worth a
follow-up but the accidental-refresh case is the noisier one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:10:06 +03:00
Arnas Donauskas
b95ce8b372
Merge pull request #120 from ArnasDon/feat/flows-node-card-previews
feat(flows): show content preview in collapsed node cards
2026-05-22 15:06:38 +03:00
Arnas Donauskas
edbbb894f6
feat(flows): make validation issues clickable to jump to node
The validation panel sits at the bottom of the editor. When it surfaced
an error ("node X is missing next_node_key") the user had to scroll up,
find that node, expand it, find the broken field. Long feedback loop.

Each node-scoped IssueLine becomes a button: click it and the editor
expands the offending node, scrolls it into view, and briefly rings
its border so the eye lands on the right card. Trigger-scoped issues
stay non-interactive (the trigger panel is already at the top of the
page).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:05:44 +03:00
Arnas Donauskas
9c7f635a4d
Merge pull request #119 from ArnasDon/feat/flows-send-list-add-section
feat(flows): add multi-section support to send_list builder
2026-05-22 15:02:20 +03:00
Arnas Donauskas
6079bf2046
feat(flows): show content preview in collapsed node cards
A flow with 8 send_message nodes used to show 8 identical-looking pills
in the editor — the only way to tell them apart was to expand each
one. Adds a one-line summary below the meta label when the card is
collapsed:

- send_message → first 80 chars of text
- send_buttons → text + button titles
- send_list → text + option count
- collect_input → prompt + target var
- condition → "var.email == 'support@…'"
- set_tag → "Add tag 1f8c…"
- handoff → first 80 chars of note

Empty nodes (just added, no fields set) keep the old compact look so
you can tell at a glance what still needs configuration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:01:53 +03:00
Arnas Donauskas
5a9a69a3c5
feat(flows): add multi-section support to send_list builder
The data model and the runner already supported multiple sections in a
WhatsApp interactive list, but the builder UI exposed no way to add one
— it only let you add rows within the single default section. So users
couldn't build a categorized menu (Billing / Support / Sales) without
editing the DB directly.

Adds an "Add section" button below the sections list (capped at the
WhatsApp spec's 10) and a per-section remove button (shown when more
than one section exists).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:56:52 +03:00
Arnas Donauskas
268ae7076f
feat(flows): hide internal node_key + reply_id behind Advanced disclosure
Node key and per-button/row reply IDs are auto-generated and consumed
only by the runner — exposing them in the main form read as developer
leakage. Move them behind a per-node "Show advanced" toggle so the
default UI shows only customer-facing fields. Power users who need
stable IDs for analytics can still get them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:52:44 +03:00
Arnas Donauskas
ed15493f95
feat(flows): templates + v1.5 nodes (collect_input/condition/set_tag) + run-history viewer (PR 4/4)
Final feature PR before GA cleanup. Closes the gap between "flow
runner works" (PR 2) and "users have everything they need to build
real automation" — three new node types, plain-text sends actually
sending, three starter templates, a run-history page for debugging.

What landed:

  1. engineSendText helper (src/lib/flows/meta-send.ts) — wraps
     sendTextMessage with the phone-variant retry + DB persistence
     pattern. Unblocks send_message + collect_input nodes that
     previously auto-advanced without sending.

  2. Three new node types in the engine:
     - collect_input: sends a prompt, suspends, captures the next
       text reply into flow_runs.vars[var_key], advances. Supports
       {{vars.X}} interpolation in downstream prompts + handoff
       notes.
     - condition: branches on subject (var / tag / contact_field)
       with operators (equals / contains / present / absent).
       Auto-advancing, no Meta call. Pure predicate logic exported
       as evaluateConditionPredicate with 7 new unit tests.
     - set_tag: upsert/delete on contact_tags then advance. Non-
       fatal on write failure — logged + advances anyway so a tag
       glitch doesn't strand the customer.

  3. Real send_message — calls engineSendText, logs the result,
     handles errors by ending the run with status='failed'. Same
     {{vars.X}} interpolation.

  4. Three starter templates (src/lib/flows/templates.ts):
     - Welcome menu (the owner's brief example)
     - FAQ bot (keyword → list → answers → end)
     - Lead capture (first_inbound → collect_input chain → handoff
       with vars in the note)
     POST /api/flows now accepts {template_slug} to clone a
     template into a fresh draft. GET /api/flows/templates exposes
     the gallery shape (no nodes payload — fetched on actual clone).

  5. Builder UI extensions:
     - NODE_META + config sub-forms for collect_input, condition,
       set_tag.
     - Template gallery on the /flows New-flow dialog (loads from
       /api/flows/templates, falls back gracefully if absent).
     - "Runs" button in the editor header → /flows/[id]/runs.

  6. Run-history viewer at /flows/[id]/runs — newest-first list of
     the 50 most recent runs, with expandable per-run event
     timeline (started, node_entered, message_sent,
     reply_received, fallback_fired, handoff, timeout, error,
     completed). Useful for debugging "why didn't my flow advance?"
     from the brief's use case.

  7. Validator updates: new error rules for each new node type
     (prompt_text required, var_key alphanumeric, operator valid,
     true_next/false_next exist, etc.). outgoingEdges helper now
     covers collect_input/condition/set_tag for reachability.

All gated behind isFlowsEnabled(profile). Non-beta accounts see no
change. Run-history API is also 404 for non-beta.

Validation: 173/173 tests pass (added 7 new for the predicate);
typecheck + lint + build all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:27:01 +03:00
Arnas Donauskas
4871d7aa06
feat(flows): no-code builder UI + flows API + validator (PR 3/4)
Ships the surface that lets opted-in beta accounts build, save, and
activate WhatsApp conversation flows entirely from the web UI — no
SQL fixtures needed. Sidebar gets a "Flows" entry (hidden for
non-beta accounts), the /flows list page surfaces existing flows,
/flows/[id] is the linear-list editor.

Wraps the runner from PR 2 with everything a real user needs to
build the welcome-menu example end-to-end.

What landed:

  1. `src/lib/flows/validate.ts` — pure validator. Checks entry
     node exists, all next_node_key refs resolve, no unreachable
     nodes, Meta-limit violations (button title ≤20, ≤3 buttons,
     ≤10 list rows, ≤24 chars per list row). 24 unit tests.

  2. `src/app/api/flows/route.ts` — GET (list user's flows),
     POST (create draft).

  3. `src/app/api/flows/[id]/route.ts` — GET (load with nodes),
     PUT (replace name + trigger + nodes; delete-then-insert),
     DELETE (cascade clean-up).

  4. `src/app/api/flows/[id]/activate/route.ts` — status toggle.
     Activation runs the validator; returns 422 + the full issue
     list when blocked.

  5. `src/app/(dashboard)/flows/page.tsx` — list of cards, "New
     flow" dialog, delete with confirm.

  6. `src/app/(dashboard)/flows/[id]/page.tsx` — editor shell
     that loads data and hands it to FlowBuilder.

  7. `src/components/flows/flow-builder.tsx` — the editor itself.
     Header with name + save/activate/pause/delete; trigger
     panel; entry-node picker; node list with expandable per-node
     config forms (start, send_buttons, send_list, send_message,
     handoff, end); bottom validation panel that mirrors what the
     server's activate endpoint would reject.

  8. `src/components/layout/sidebar.tsx` — Flows entry between
     Automations and Settings, rendered only when isFlowsEnabled.

Every API route + page is gated server-side by isFlowsEnabled —
non-beta accounts get 404 (not 403) so the routes don't even hint
at existing. UI gate is for snappy redirect; security is at the
API layer.

Validation: 166/166 tests pass (added 24 validator tests);
typecheck + lint + build all clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:57:38 +03:00
Arnas Donauskas
208b096cb1
chore(flows): defensive gate on interactive bubble render
Wraps the new `case 'interactive'` rendering with `isFlowsEnabled(profile)`
from the beta-features module that landed in #113. Non-beta accounts
fall back to the plain-text rendering for `content_type='interactive'`
rows — they get the option title as text, no special "↩ Button reply"
affordance.

Why this gate exists even though the case is unreachable today:
  - No code path on this branch produces `content_type='interactive'`
    rows. The flow runner that does is upcoming (PR #2), and it'll be
    behind the same gate.
  - But: if a stray interactive row ever shows up in a non-beta
    account (manual SQL insert, data import, future bug), we'd rather
    the account see boring plain text than UI for a feature they
    haven't opted into.
  - The cost is ~3 LoC and one hook call. Pure paranoia insurance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:21:22 +03:00
Arnas Donauskas
dc245d5914
feat(flows): foundation for interactive WhatsApp messages (PR 1/4)
Foundation for the upcoming WhatsApp chatbot flow builder. No runner,
no UI yet — just the plumbing that lets the codebase SEND interactive
messages (button replies + lists) and RECEIVE the taps as structured
message rows. Plan: ~/.claude/plans/understood-research-such-functionality-binary-lake.md

## What's in this PR

### Migration 010_flows.sql
- New tables: flows, flow_nodes, flow_runs, flow_run_events
- Linchpin partial unique index on flow_runs(user_id, contact_id) WHERE status='active' — at most one active run per contact, makes concurrent webhook deliveries idempotent at the DB layer
- Widens messages.content_type CHECK to allow 'interactive'
- New messages.interactive_reply_id column for the tapped button/row id
- RLS on every table; flow_runs added to supabase_realtime publication
- Idempotent throughout — matches the 009_message_actions.sql template

### Interactive Meta senders (src/lib/whatsapp/meta-api.ts)
- sendInteractiveButtons: type='interactive', interactive.type='button', up to 3 reply buttons
- sendInteractiveList: type='interactive', interactive.type='list', up to 10 rows across sections
- Limits exported as INTERACTIVE_LIMITS so flow validation can share the constants
- All Meta caps validated BEFORE the network call (button title ≤20 chars, list row title ≤24 chars, ≤10 total rows, no duplicate row ids, body ≤1024 chars, etc.) — misconfigured flows fail at save time instead of as a customer-facing 400 mid-conversation

### Webhook (src/app/api/whatsapp/webhook/route.ts)
- Extends WhatsAppMessage interface with `interactive?: { type, button_reply?, list_reply? }`
- parseMessageContent returns a new `interactiveReplyId` field; refactored to use a shared `empty` base so every return site picks up the new field without 9 explicit copies
- New `case 'interactive'`: uses the tapped option's TITLE as content_text (so the inbox bubble reads "Existing customer", not a UUID) and stashes the id separately
- 'interactive' added to ALLOWED_CONTENT_TYPES so the messages insert no longer coerces it to 'text'

### Inbox bubble (src/components/inbox/message-bubble.tsx)
- New `case "interactive"` in MessageContent renders the tap with a small "↩ Button reply" affordance so agents reading the inbox can tell at a glance that this was a tap rather than the customer typing the same words

### Types (src/types/index.ts)
- ContentType union extended with 'interactive'
- Message.interactive_reply_id field

### Engine helpers (src/lib/flows/)
- admin-client.ts: thin service-role client wrapper, mirrors automations/admin-client.ts
- meta-send.ts: engineSendInteractiveButtons / engineSendInteractiveList — adapts the Meta senders for service-role use, persists messages with sender_type='bot' content_type='interactive', uses the same phone-variant retry as automations/meta-send.ts. Unused in PR #1; ready for the runner in PR #2

### Tests
- 13 new vitest cases in meta-api.test.ts covering Meta's limit validation + payload-shape assertions for both interactive senders

## Validation
- npm test → 102/102 (was 89; 13 new)
- npm run lint → 0 errors
- npm run typecheck → clean
- npm run build → clean

## What's NOT in this PR
- The runner (dispatchInboundToFlows) — PR #2
- Builder UI / API surface — PR #3
- collect_input / condition / set_tag nodes + templates — PR #4
- react-flow canvas + http_fetch — v2 (PR #5)

## Risks tracked from the plan
- messages.content_type CHECK widening: grep'd every consumer; message-bubble.tsx, reply-quote.tsx, send/route.ts, automations/meta-send.ts all handle the new variant correctly (or accept it as a defaulted fallthrough)
- engineSendText / engineSendInteractive share ~80% of code; deliberately not pre-factored yet — let both stabilize, extract a shared base in PR #2 once the shape settles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:12:27 +03:00
Arnas Donauskas
a701d932d2
Merge pull request #111 from ArnasDon/feat/inbox-doodle-background
feat(inbox): WhatsApp-style doodle background in the chat area
2026-05-22 10:09:16 +03:00
Arnas Donauskas
598ac7e66d
feat(inbox): WhatsApp-style doodle background in the chat area
Adds a subtle, repeating SVG pattern behind the message thread and the
"select a conversation" empty state — same visual language WhatsApp uses
in their own chat UI, themed for a CRM (message bubble, phone, mail,
calendar, lightbulb, briefcase, trophy, dollar sign, percent, etc).

Asset: public/inbox-doodle.svg, 6 KB, 240×240 tile that repeats
seamlessly. Icon shapes are taken verbatim from lucide-react (already
a dependency, every other icon in the UI uses it) so the doodle reads
as part of the same visual system, not a separate clip-art import.
Stroke is slate-500 at 22% opacity over bg-slate-950 — visible but
not competing with message text.

Wiring: a single DOODLE_BG_CLASSES constant in message-thread.tsx
applied to both the empty-state and active-thread containers, so they
can't drift. The header keeps bg-slate-900 (solid) and the composer
keeps its own background, so the doodle only shows in the actual
message scroll area — exactly like WhatsApp.

No JS, no perf hit beyond a one-off 6 KB asset fetch that's then
browser-cached forever (it's served from /public with Next's default
caching).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:06:34 +03:00
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
d41652d748
fix(inbox): scope hover to the bubble; fix stale-closure reaction flow
Two follow-ups to the chat-actions PR:

1. Action toolbar was revealing across the whole message row, not just
   over the bubble. The `group/actions` div in MessageActions wrapped
   the bubble's full-width `flex w-full justify-end/start` row, so
   anywhere in that row triggered group-hover. Moved row alignment +
   max-w-[75%] cap onto MessageActions and reduced MessageBubble to a
   content-sized stack. Hover area now matches the bubble shape.

2. Agent reactions weren't appearing locally. `handleAddReaction` was
   wrapped in `useCallback` with a `[]` dep array, so it captured the
   first-render `postReaction` — whose own closure held a stale
   `reactions = []`, stale `conversation`, and stale `user`. Every
   optimistic update replaced the reactions list with `[onlyMyTemp]`
   and the API POST may have hit a null conversation. Dropped both
   wrappers; postReaction is now called directly from JSX and uses a
   functional `setReactions((prev) => ...)` so each call sees fresh
   state. Toggle semantics for pill-click are computed at the call site
   where `msgReactions` and `user?.id` are already in scope. Realtime
   INSERT now swaps a matching optimistic temp row instead of stacking
   a duplicate.

Also: reject `temp-*` message IDs with a friendly toast so users can't
react to a bubble that hasn't reached Meta yet.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:32:34 +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
fbb95eb2a7
Merge branch 'main' of https://github.com/ArnasDon/wacrm into feat/inbox-template-picker
# Conflicts:
#	src/components/inbox/message-thread.tsx
2026-05-18 15:43:39 +03:00
Arnas Donauskas
149a4e67ef
Merge pull request #91 from ArnasDon/chore/remove-broadcast-schedule-stub
chore(broadcasts): drop the inert Schedule for Later tile
2026-05-18 15:39:54 +03:00
Arnas Donauskas
8d83220df4
chore(broadcasts): drop the inert Schedule for Later tile
The Pro-badged Schedule for Later tile in step 4 was a non-interactive
div — no handler, no scheduling backend, and no Pro tier in the product.
Send Immediately was the only path that did anything, so the picker
itself was decorative. Remove the section (and the now-unused timing
state / Clock / Lock imports). The wizard still completes Send
Immediately end-to-end via the existing Send Broadcast button below.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:26:47 +03:00
Arnas Donauskas
2a86781c85
feat(inbox): wire up the template picker dialog
handleOpenTemplates flipped templateModalOpen but nothing rendered when
true, so both composer template buttons were dead. Add a TemplatePicker
dialog that lists Approved templates, prompts for any {{N}} variables,
then sends through the existing /api/whatsapp/send route with
message_type=template. The rendered body is stored as content_text so
the thread bubble and conversation preview show the actual message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 15:25:45 +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
15a05eb280
Extract marketing site to ArnasDon/wacrm-site
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>
2026-05-18 12:21:13 +03:00
Arnas Donauskas
9bdbfead6f
Merge pull request #86 from ArnasDon/chore/footer-trademark-disclaimer
chore: add Meta trademark / non-affiliation disclaimer to public footers
2026-05-06 13:54:43 +03:00
Arnas Donauskas
1f2849df75
Merge remote-tracking branch 'origin/main' into chore/whatsapp-trademark-symbol
# Conflicts:
#	README.md
#	docs/README.md
#	docs/automations-and-cron.md
#	docs/supabase-setup.md
#	docs/whatsapp-setup.md
#	src/app/opengraph-image.tsx
#	src/components/landing/hero.tsx
#	src/lib/seo/site-config.ts
2026-05-06 13:50:03 +03:00
Arnas Donauskas
4bb157c705
Merge remote-tracking branch 'origin/main' into chore/hostinger-accent-palette
# Conflicts:
#	src/app/icon.tsx
#	src/components/docs/shell.tsx
#	src/components/landing/footer.tsx
#	src/components/landing/nav.tsx
2026-05-06 13:35:46 +03:00
Arnas Donauskas
c3dde01229
chore: add Meta trademark / non-affiliation disclaimer to public footers
Per Meta's brand-use guidance for third-party products, sites that
reference WhatsApp must include a clear non-affiliation disclaimer in
the footer. Adds the standard line:

  "WhatsApp® is a registered trademark of Meta Platforms, Inc.
   Hostinger is not affiliated with, endorsed by, or sponsored by
   Meta Platforms, Inc."

Placed in:
- src/components/landing/footer.tsx — marketing footer (every public
  landing page).
- src/components/docs/shell.tsx — public docs pages had no footer at
  all; added a slim one carrying the same disclaimer.

Out of scope:
- Authed dashboard pages don't have a footer (full-screen workspace
  layout). The trademark-fair-use risk is concentrated on public
  surfaces — crawlers, visitors who haven't signed up, social shares.
  Skipping the dashboard keeps the workspace layout intact and the
  disclaimer where it does the most work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 12:15:08 +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
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
faee4eea44
chore: rename product to "CRM Template for WhatsApp"
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>
2026-05-06 11:52:11 +03:00
Arnas Donauskas
047b981821
ci: clear lint error backlog, drop continue-on-error on lint step
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>
2026-04-20 22:28:35 +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
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
72a5e4cf70
feat(settings): user profile management
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>
2026-04-20 17:40:39 +03:00
Arnas Donauskas
45fd2a9845
feat(docs): in-app docs viewer at /docs
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>
2026-04-19 21:28:36 +03:00
Arnas Donauskas
029a20e407
fix(hostinger): recommend Managed Node.js Hosting, not VPS
Swaps the Hostinger recommendation on the landing page and throughout the
docs from VPS to Managed Node.js Hosting
(https://www.hostinger.com/web-apps-hosting).

The deploy walkthrough is rewritten around hPanel's Node.js app flow
(Git-based deploy, env vars panel, AutoSSL, cron via Advanced → Cron Jobs)
instead of a raw Ubuntu VPS with nginx + PM2 + certbot. VPS stays
mentioned at the bottom of the deploy page as an alternative when a
user actually needs one (background workers, custom binaries, etc.).

Also touched:
  - docs/README.md, environment-variables.md, automations-and-cron.md,
    troubleshooting.md to drop VPS-specific assumptions.
  - footer link URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 21:02:10 +03:00
Arnas Donauskas
dd65e532a1
feat(landing): open-source + Hostinger deploy section
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>
2026-04-19 20:45:00 +03:00
Arnas Donauskas
9173e8e4aa
Merge pull request #51 from ArnasDon/feat/mobile-automation-builder
feat(mobile): automation builder fits + flows on narrow screens
2026-04-19 12:58:11 +03:00
Arnas Donauskas
9f4c34e945
Merge pull request #50 from ArnasDon/feat/mobile-pipelines-kanban
feat(mobile): snap-scroll kanban with viewport-sized columns
2026-04-19 12:56:59 +03:00