The page wrapper around the message thread already carries `min-w-0`
(issue #165), but the MessageThread root itself kept the default
`min-width: auto`. A single wide message (a long unbroken URL or word)
expanded the whole thread past its flex share, so at lg+ the chat
painted on top of the contact sidebar: outgoing bubbles were clipped on
the right and the hover toolbar floated over the Tags panel.
Adding `min-w-0` to the thread root lets it shrink to its column so the
bubbles' existing break-words / max-w-[75%] caps actually apply. Verified
in-browser: a 1272px-wide column went from 962px of overlap to 0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add row selection to the contacts table so multiple contacts can be
deleted at once instead of one at a time.
- New Checkbox UI primitive wrapping @base-ui/react/checkbox, matching
the switch.tsx token conventions (incl. indeterminate state).
- Header select-all + per-row checkboxes; row-checkbox clicks stop
propagation so they don't open the detail sheet.
- Bulk action bar with selected count, Clear, and a role-gated
"Delete selected" action.
- handleBulkDelete via .delete().in('id', ids) — RLS already restricts
deletes to account members, so no migration/API route is needed.
- Selection is page-scoped and resets whenever the contact set reloads.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds an "Unread" option to the Inbox conversation-list filter dropdown,
showing only conversations with unread messages (unread_count > 0).
Closes#233
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sweep over-tokenized the neutral status badges in the three status
data modules to bg-muted/border-border, which broke
broadcast-status.test.ts ("each variant has the dark-theme class
triple") — it asserts every badge is bg-*/10 + text-* + border-*/20.
The badge system intentionally uses fixed palette colors at low opacity
(translucent fills are mode-tolerant). Restore the original slate fills
(so dark is identical and the triple contract holds) but keep
text-muted-foreground for the label, so neutral statuses stay legible in
light mode where a solid slate-400 would be too faint on white.
- broadcast-status: draft / recipient pending
- template-status: DRAFT / PENDING_DELETION
- trigger-meta: time_based / fallback
All 400 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-review of the light/dark sweep surfaced neutral colors that lived
in raw inline-style / <style jsx> / gradient values (so the className
sweep correctly skipped them) and a few contrast/affordance nits:
- reply-quote: adapt to the outbound (primary) bubble via onPrimary so
the quote/author stay legible on the violet fill in light mode; wire
it from message-bubble.
- dashboard SVG chrome -> tokens: conversations-chart gridline/crosshair,
pipeline-donut background ring, automation-builder dot-grid gradient.
- members-tab viewer chip: bg-card -> bg-transparent (outline) so it
doesn't blend into a card surface in light mode.
- tag-manager / pipeline-settings color-swatch selection rings: raw
slate/white -> var(--popover)/var(--foreground) so they flip.
- pipeline-board scrollbar (<style jsx>): slate rgb -> var tokens.
- activity-feed zebra stripe: bg-card/40 -> bg-muted/40 (survives light).
- forgot-password / import-modal: restore real hover (dead no-op hovers).
- themed-toaster: gate theme via useSyncExternalStore to avoid a
hydration mismatch on sonner's theme attribute (no setState-in-effect).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
- Reformatted code in `import-modal.tsx` for better readability, including consistent indentation and line breaks.
- Enhanced `parse-contact-csv.ts` and `parse-contact-csv.test.ts` with consistent string delimiters and improved whitespace handling.
- Updated comments for clarity and consistency across the import functionality.
- Ensured consistent use of single quotes for strings throughout the files.
The WhatsApp config POST handler hard-failed with a 400 whenever a
number needed registration (first save or number change) and no
6-digit two-step-verification PIN was supplied. Meta test numbers
(Developer Console) are pre-registered by Meta and expose no PIN to
set, so test-number users could never complete setup (issue #242).
The PIN-based /{phone_number_id}/register call only governs inbound
webhook routing for production numbers under a shared WABA (issue
#136) — it has nothing to do with credential validity or outbound
sends. So treat it as best-effort: when no PIN is supplied, skip
/register, save the (already Meta-verified) credentials as connected,
and leave registered_at null. The response now reports `registered`
accurately and adds `registration_skipped` so the UI can react.
UI: the save handler no longer falsely claims the number is "Live"
on a skipped registration — it points at the existing "Not
registered" banner, which already offers a PIN-retry path. The PIN
field is relabelled (optional) with copy explaining it's only for
production inbound routing and that test numbers have no PIN.
Fixes#242
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Updated `parseContactCsv` to include a flag for the presence of a company column in the CSV.
- Modified the import modal state management to handle the new company column, improving user experience during contact import.
- Enhanced the `ImportPreviewTags` component to conditionally render based on the presence of company data.
- Adjusted the UI to better display tag and company information in the import preview.
- Added `ImportPreviewTags` component to display tags with color coding in the import modal.
- Updated `parseContactCsv` to return an object containing both parsed rows and a flag indicating the presence of a tags column.
- Modified state management in the import modal to handle tags and their colors, improving user experience during contact import.
- Adjusted the import modal to conditionally render the tags column based on the presence of tags in the preview.
- Introduced `parseContactCsv` for improved CSV parsing, replacing the previous inline parsing logic.
- Added support for importing tags alongside contacts, allowing users to assign tags during the import process.
- Implemented `assignImportedContactTags` to handle tag assignments after contact creation.
- Updated the import result state to include the number of tags assigned and added relevant success and info notifications for users.
- Enhanced the import modal description to clarify the format for tag input in CSV files.
Custom-field definitions are account-wide config — a sibling of Tags,
Templates and Deals, all of which live under Settings. But the field
catalogue could only be managed via an admin-only button on the Contacts
page, so users looking under Settings found nothing (#238).
Surface it as a "Custom Fields" tab in Settings:
- Extract the manager's body into a reusable `CustomFieldsPanel`;
`CustomFieldsManager` keeps its dialog signature and now wraps the
panel, so the Contacts page is unchanged. Radix unmounts dialog
content on close, so the panel still refetches on each open.
- New `CustomFieldsSettings` renders the panel inline with a
settings-style header.
- Tab is admin+ gated (mirrors the Contacts gate); deep-linking to it
as a non-admin falls back to Profile. `custom_fields` RLS still
enforces writes server-side.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "Update Contact Field" automation action could only write the built-in
name/email/company columns. This wires up the existing custom_fields /
contact_custom_values tables so automations can read and update arbitrary
custom fields, and adds the missing UI to define them.
- Add a "Custom fields" manager dialog on the Contacts page (admin+ gated)
to create / rename / delete field definitions. Inserts carry account_id.
- Load account custom fields into the automation builder's ResourcesContext
and offer them in the Field dropdown, encoded as `custom:<id>`. A saved
field that's since been deleted is preserved as an "(unknown field)"
option so editing doesn't silently drop it.
- Engine: interpolate the value ({{ vars.* }} / {{ message.text }}) for all
fields, and for `custom:<id>` verify the field belongs to the automation's
account (defense in depth) before upserting into contact_custom_values.
- Tests: cover the custom-value upsert, variable interpolation, and
cross-account rejection.
Closes#235
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The list-view keyword trigger input parsed its value into the keywords
array on every keystroke (split/trim/filter(Boolean)) and re-rendered
from keywords.join(", "). Typing a comma produced a trailing empty
segment that filter(Boolean) dropped, so the comma was stripped the
instant it was typed — making it impossible to enter a second keyword.
Extract a KeywordsInput component that holds a local draft string and
only commits (parse + dedupe-empties + rejoin) on blur / Enter, mirroring
the automations builder's KeywordMatchConfig. Multi-keyword entry like
"support, help, hi" now works.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The conversation/contact list on the Inbox renders inside a flex
column with a fixed height, with the list in a `<ScrollArea
className="flex-1">`. A flex child defaults to `min-height: auto`, so
the ScrollArea grew to fit every conversation rather than shrinking to
the remaining space. The list then overflowed its container and was
clipped by the parent's `overflow-hidden`, with no vertical scrollbar —
so contacts past the fold were unreachable (issue #229).
Add `min-h-0` to the ScrollArea so it can shrink below its content and
the viewport scrolls.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The same phone could become multiple contacts: only the WhatsApp
webhook de-duped, while manual create and CSV import inserted raw
un-normalized phones, and the DB had no uniqueness. That fragmented
conversations, deals, and tags across duplicates.
- Migration 022: generated `contacts.phone_normalized` (digits-only),
a one-shot `merge_duplicate_contacts()` that collapses existing
dupes into the oldest row (re-pointing conversations, deals, notes,
tags, custom values, broadcast recipients — no data loss), then a
UNIQUE (account_id, phone_normalized) index as the authoritative
guarantee for every write path.
- Shared `src/lib/contacts/dedupe.ts`: findExistingContact (suffix
prefilter + phonesMatch), isExactMatch, isUniqueViolation,
dedupeByPhone. The webhook now reuses findExistingContact (and
recovers from a unique-violation race instead of dropping the
message).
- Contact form: on-blur duplicate check — hard-block + "view existing"
link on an exact match, soft warning on a trunk-variant near-match;
unique-violation backstop on submit.
- CSV import: de-dupes within the file and against existing contacts,
reports "imported / skipped / failed", treats 23505 as skipped.
- Contact type gains account_id (was missing) + phone_normalized.
- Unit tests for the dedupe helpers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the Unreleased entry for the per-account default currency under
Added, plus a "Migration required" note for 021 with the
apply-before-deploy warning.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
deals.currency has no DB CHECK (only accounts.default_currency does),
so legacy rows, imports, or hand-edited data can hold a structurally
invalid code (e.g. "United States"). Intl.NumberFormat throws a
RangeError on such codes, which would crash any deal-card / contact
render that passes deal.currency straight through.
Guard formatCurrency with a try/catch that falls back to "CODE 1,234"
so a bad value degrades gracefully instead of taking down the view.
Add currency.test.ts locking in the defaults, the no-throw contract,
and formatCurrencyShort's symbol/fallback behavior.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
wacrm hardcoded USD across every deal-value formatter, the new-deal
form, and automation-created deals, making deal tracking unhelpful for
non-US self-hosted users. Add a per-account default currency.
- Migration 021: accounts.default_currency TEXT NOT NULL DEFAULT 'USD'
with a 3-letter-code CHECK. The existing admin-only accounts_update
RLS policy already gates writes.
- New src/lib/currency.ts: single source of truth (CURRENCIES list,
formatCurrency, formatCurrencyShort), replacing ~6 duplicated
USD-hardcoded helpers.
- useAuth exposes defaultCurrency (joined from accounts), falling back
to USD while loading / on pre-021 schemas.
- Settings → Deals tab to pick the currency (admin-only, read-only for
others to match RLS).
- Deal form defaults new deals to the account currency and sources its
dropdown from CURRENCIES; existing deals keep their saved currency.
- Pipeline board/analytics, dashboard "Open Deals Value", and the
pipeline donut format totals in the account currency. Individual
deal cards still show each deal's own currency.
- Automation create_deal sets the new deal's currency from the
account default instead of the static DB default.
One currency per account (no FX conversion) per the issue's
recommended first pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The automation builder exposed several developer-facing fields that
made automations hard to configure from the UI (issue #216):
- Keyword Match parsed/trimmed on every keystroke, so typing a
comma-separated, multi-word list like "SEO, search engine
optimization" was impossible — the comma and trailing space were
stripped immediately.
- Tag Added / Add Tag / Remove Tag asked for a raw tag_id.
- Assign Conversation → Specific agent asked for a raw agent_id.
- Send Template asked for a manually typed template name + language.
Changes:
- Keyword input keeps a local draft and only parses into the keywords
array on blur / Enter, then re-displays the cleaned form.
- New tag / agent / template pickers select existing resources by name
while still storing ids / names internally, so the stored config
shape (and the engine/validation) is unchanged. Resources load once
at the builder root and are shared via context.
- Tags & approved templates load from Supabase (RLS-scoped, matching
the broadcast picker); members load from /api/account/members.
- Every picker falls back to its original raw input when its list is
empty (fresh account / older deployment), so automations stay
authorable. Saved-but-missing ids are preserved when editing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The pipeline board uses overflow-x-auto but hid the scrollbar on fine
pointers, assuming the full board always fits on lg+. That assumption
breaks once a pipeline has many stages: columns keep a 260px min-width,
so the row overflows the viewport and the rightmost stage gets clipped
with no scrollbar or affordance to reveal it (issue #214).
Scope the scrollbar-hiding to touch devices (where the snap/peek layout
already hints at more content) and show a thin, themed horizontal
scrollbar on desktop so the overflow is discoverable and usable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`flows.account_id` and `pipelines.account_id` are NOT NULL post-017 with
no DB default, so inserts omitting account_id fail with a not-null
violation (same class as #206 / #205).
- POST /api/flows: resolve the caller's profiles.account_id and stamp it
on both insert paths (template clone + blank/manual). Closes#210.
- Pipelines page: stamp account_id (from useAuth) on the auto-seed and
the manual "create pipeline" inserts — same bug, found while auditing
other account-scoped insert flows.
Audited every insert into the 15 NOT-NULL-account_id tables; all other
sites already stamp account_id. typecheck + 370 tests pass; lint clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>