Adds a "Send template" action to the contact detail view so the
business can initiate (or re-open) a WhatsApp conversation with one
known contact by sending an approved template — without the awkward
single-recipient broadcast workaround.
The /api/whatsapp/send route now accepts `contact_id` as an
alternative to `conversation_id`: it verifies the contact is in the
caller's account, find-or-creates the contact's conversation
server-side, then runs the existing send path (phone-variant retry,
template send-builder, message + conversation persistence, flow
pause). The contact view reuses the existing inbox TemplatePicker for
template selection + variable/header/button input, keeping Meta
compliance unchanged (approved templates only).
Co-Authored-By: Claude Opus 4.8 <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.
- 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 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>
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>
Migration 017 made domain tables account-scoped: account_id is NOT NULL
and INSERT RLS is WITH CHECK (is_account_member(account_id, ...)). Several
client- and server-side create flows still inserted only user_id, so on a
fresh install they failed with 403 / "new row violates row-level security
policy" / not-null violations.
Resolve account_id from useAuth() (client) or the caller's profile (server)
and include it on every account-scoped insert:
- contacts: contact-form, import-modal, broadcast CSV upsert
- contact_notes: contact-sidebar, contact-detail-view
- tags: tag-manager
- broadcasts: broadcasts/new page, use-broadcast-sending
- deals: deal-form
- automations: POST /api/automations, POST /api/automations/[id]/duplicate
Each path also guards on a missing accountId with a clear message instead
of letting the insert fail opaquely.
pipelines/page.tsx and api/flows/route.ts are intentionally left to the
already-open PR #193, which fixes those same paths (and adds tests).
Verified: a full sweep of every account-scoped insert/upsert across src/
now includes account_id (except the two paths owned by #193). typecheck,
lint (0 errors), and 370 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Rebuild the Pipelines page to match spec.
Highlights:
- Drag-and-drop deal cards between stages via @dnd-kit (200ms drop animation,
emerald dashed drop zone, opacity fade while dragging)
- First-visit seed: auto-creates "Sales Pipeline" with spec-defined stages
(New Lead / Qualified / Proposal Sent / Negotiation / Won)
- Top bar: always-visible pipeline dropdown with "Manage Pipelines" option,
plus "Add Pipeline" and emerald "Add Deal" buttons
- Deal form converted from Dialog to right-side Sheet, adds assigned_to
(profile FK), "Link to Conversation" shortcut, "Mark as Won/Lost" status
buttons, and "Delete Deal"
- Pipeline settings modal: drag-to-reorder stages (@dnd-kit sortable),
per-stage color picker, "Create new pipeline" shortcut, archive
confirmation copy
- Analytics bar below board: total deals, pipeline value, avg deal size,
weighted pipeline value (probability interpolated across stages),
won/lost this month
- Contact detail sheet gains a "Deals" tab showing all deals for that
contact (value + stage badge + status)
- Migration 002: adds deals.assigned_to FK→profiles, normalizes status
CHECK to ('open','won','lost')
Note: spec called for light-gray Kanban columns (#f9fafb) + white cards
inside an otherwise-dark app. Followed spec literally; easy to retheme.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Major performance improvement. Every client component that needed the
current user was calling supabase.auth.getUser(), which makes a
network round-trip to Supabase to validate the JWT. On a page that
fetches data conditional on the user, the flow was:
Page mount
-> getUser() [network ~150-400ms]
-> query data [network ~150-400ms]
-> render
That's 2 serial round-trips before the page renders.
supabase.auth.getSession() reads the session from localStorage with
NO network call. Using it in client code cuts per-page load by one
full round-trip (~150-400ms depending on region latency).
Replaced in 10 client files:
- src/app/(dashboard)/inbox/page.tsx
- src/app/(dashboard)/pipelines/page.tsx
- src/components/contacts/contact-detail-view.tsx
- src/components/contacts/contact-form.tsx
- src/components/contacts/import-modal.tsx
- src/components/inbox/contact-sidebar.tsx
- src/components/pipelines/deal-form.tsx
- src/components/pipelines/pipeline-settings.tsx
- src/components/settings/tag-manager.tsx
- src/components/settings/template-manager.tsx
- src/components/settings/whatsapp-config.tsx
NOT changed (correctly continue using getUser):
- src/middleware.ts (server-side, needs JWT validation)
- src/app/api/whatsapp/*/route.ts (server-side, security-critical)
- src/hooks/use-auth.ts (PR #4 handles this file)
Security note: getSession() trusts the client-stored session. For
API routes and middleware that make authorization decisions, we still
use getUser() which verifies with Supabase. Client components only
use the session's user ID to filter RLS-protected queries — the RLS
on the database is the actual authorization boundary, so a forged
user ID in the browser wouldn't bypass server-side policies.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>