Merge branch 'ArnasDon:main' into main

This commit is contained in:
Alex Titus Paul 2026-06-30 10:44:07 +05:30 committed by GitHub
commit cf842fcee2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
155 changed files with 9904 additions and 3265 deletions

View File

@ -7,8 +7,14 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# Supabase service-role key. Bypasses RLS; used only by server-side
# routes (the webhook and automation engine). Keep this secret —
# never paste it into client code.
# routes (the webhook, automation engine, and the public API key
# auth path — see docs/public-api.md). Keep this secret — never
# paste it into client code.
#
# Note: the public API (/api/v1) needs no new env var. API keys are
# created in the dashboard (Settings → API keys) and stored hashed
# in the database; this service-role key is what lets the auth path
# look a presented key up without a user session.
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# WhatsApp token encryption (64 hex chars = 32 bytes, AES-256-GCM).
@ -72,10 +78,12 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com
# See docs/automations-and-cron.md.
# AUTOMATION_CRON_SECRET=generate-a-long-random-string
# Meta App ID (Meta for Developers → App Settings → Basic). Required
# only for the (planned) Resumable Upload flow that lets users attach
# local media files as template header samples. Submitting templates
# with a public sample URL works without it. Pair with META_APP_SECRET.
# Meta App ID (Meta for Developers → App Settings → Basic). Required to
# create/edit message templates with an IMAGE header: Meta only accepts a
# Resumable-Upload media handle (not a plain URL) as the header sample, and
# that upload is app-scoped. Without it, image-header template submission
# returns a clear error; text/body-only templates are unaffected. Pair
# with META_APP_SECRET.
# META_APP_ID=your-meta-app-id
# When "true", POST /api/whatsapp/templates/submit skips the Meta call

View File

@ -30,7 +30,7 @@ jobs:
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
META_APP_SECRET: 'ci-dummy-meta-secret'
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:

View File

@ -7,3 +7,4 @@ next-env.d.ts
package-lock.json
# Generated / vendored
supabase/migrations
public/opus

View File

@ -23,6 +23,20 @@ as the sole owner of their own account and sees identical data, and a
solo owner who never invites anyone sees the same single-user app they
always did.
### Added
- **Public REST API (`/api/v1`) — groundwork.** A scoped, revocable
**API key** system so you can drive wacrm from your own scripts and
automations. Create keys under **Settings → API keys** (admin+),
grant only the scopes each integration needs, and authenticate with
`Authorization: Bearer <key>`. Keys are account-scoped and stored
hashed (plaintext shown once). This release ships the auth layer,
scopes, per-key rate limiting, the management UI, and a
`GET /api/v1/me` probe to verify a key; the data endpoints
(`messages`, `contacts`, …) follow one at a time. See
`docs/public-api.md`. **Migration required:** apply
`supabase/migrations/026_api_keys.sql`. ([#245](https://github.com/ArnasDon/wacrm/issues/245))
### Changed
- **Tenancy moves from per-user to per-account.** RLS on every

View File

@ -40,6 +40,9 @@ clone or fork it to run your own CRM.
is account-scoped, so one shared inbox can be staffed by a whole
team. Solo use stays single-user with zero setup.
- **Account management** — email, password, avatar, global sign-out.
- **Public REST API** (`/api/v1`) with scoped, revocable API keys —
build your own automations on top of your CRM. See
[docs/public-api.md](./docs/public-api.md).
## Why fork this?

130
docs/public-api.md Normal file
View File

@ -0,0 +1,130 @@
# Public API (`/api/v1`)
The public API lets you drive your wacrm instance from your own
scripts and automations — send messages, manage contacts, launch
broadcasts — without going through the dashboard UI.
> **Status:** groundwork release. Authentication, scopes, rate
> limiting, and the `GET /api/v1/me` probe ship now. The data
> endpoints (`messages`, `contacts`, …) land one at a time in
> follow-up releases — see [Roadmap](#roadmap).
## Authentication
Every request authenticates with an **API key**, sent as a bearer
token:
```
Authorization: Bearer wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Keys are **account-scoped**: a key acts on exactly one account, the
one it was created in. There is no cross-account access.
### Creating a key
In the dashboard: **Settings → API keys → New API key**. Only
**admins and owners** can create keys.
1. Give the key a name (after the integration that will use it).
2. Grant the **scopes** it needs — nothing more (see below).
3. Copy the key. **The full key is shown exactly once.** wacrm
stores only a SHA-256 hash, so it can never be shown again. If you
lose it, revoke it and create a new one.
### Revoking a key
**Settings → API keys → Revoke.** Revocation is effective on the
key's next request. Revoked keys stay in the list as an audit trail.
## Scopes
A key can do only what its scopes allow — independent of who created
it. Grant the minimum.
| Scope | Allows |
| -------------------- | ---------------------------------------- |
| `messages:send` | Send WhatsApp messages |
| `messages:read` | Read messages and delivery status |
| `contacts:read` | List and read contacts |
| `contacts:write` | Create and update contacts |
| `conversations:read` | List and read conversations |
| `broadcasts:send` | Launch broadcast campaigns |
A key with **no scopes** still authenticates and can call
`GET /api/v1/me` — useful for verifying a key works.
## Response envelope
Every response uses one of two shapes:
```jsonc
// success
{ "data": { /* ... */ } }
// failure
{ "error": { "code": "forbidden", "message": "This API key is missing the 'messages:send' scope" } }
```
Branch on `error.code` (stable); `error.message` is for humans and
may be reworded.
| Status | `code` | Meaning |
| ------ | -------------- | ------------------------------------------------ |
| 401 | `unauthorized` | Missing / malformed / unknown / revoked / expired key |
| 403 | `forbidden` | Valid key, but missing the required scope |
| 429 | `rate_limited` | Per-key rate limit exceeded |
| 400 | `bad_request` | Malformed input |
| 404 | `not_found` | No such resource |
| 500 | `internal` | Server error |
## Rate limits
Requests are limited **per key**: **120 requests per minute**. On a
`429`, these headers tell you when to retry:
- `Retry-After` — seconds until the window resets
- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
> The limiter is in-memory and **per process**. A single-instance
> deploy (the common case for a self-hosted fork) is fine as-is. If
> you scale to multiple instances, swap the limiter for a shared
> store (Redis/Upstash) — see the note at the top of
> `src/lib/rate-limit.ts`. The limit is otherwise unenforced across
> instances.
## Endpoints
### `GET /api/v1/me`
Returns the account a key is bound to and the scopes it carries.
Requires only a valid key (no scope). Use it to verify a key works
and to discover its scopes.
```bash
curl https://your-crm.example.com/api/v1/me \
-H "Authorization: Bearer wacrm_live_xxx"
```
```json
{
"data": {
"account": { "id": "…", "name": "Acme Inc" },
"key": { "id": "…", "scopes": ["messages:send"] }
}
}
```
## Roadmap
Planned endpoints, shipping one per release (tracked in
[#245](https://github.com/ArnasDon/wacrm/issues/245)):
- `POST /api/v1/messages` — send a message to a phone number
(`messages:send`)
- `GET/POST /api/v1/contacts`, `GET/PATCH /api/v1/contacts/{id}`
(`contacts:read` / `contacts:write`)
- `GET /api/v1/conversations` (`conversations:read`)
- `POST /api/v1/broadcasts` (`broadcasts:send`)
- Outbound event webhooks (so automations can react to inbound
messages)

View File

@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
"out/**",
"build/**",
"next-env.d.ts",
// Vendored minified opus-recorder encoder worker (served statically).
"public/opus/**",
]),
]);

View File

@ -25,8 +25,12 @@ const SECURITY_HEADERS = [
{ key: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{
// Microphone is allowed for same-origin (`self`) so the inbox
// composer can record voice notes via MediaRecorder. Everything
// else stays denied — a compromised dependency can't silently grab
// the camera / geolocation / etc.
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
value: "camera=(), microphone=(self), geolocation=(), payment=(), usb=()",
},
{
key: "Content-Security-Policy-Report-Only",
@ -42,6 +46,9 @@ const SECURITY_HEADERS = [
// https URLs paste-able from the UI), OG images, data URLs for
// tiny inline assets.
"img-src 'self' data: blob: https:",
// Outbound media previews (blob: from MediaRecorder + file picker)
// and Supabase public-bucket audio/video the inbox renders.
"media-src 'self' blob: https://*.supabase.co",
"font-src 'self' data:",
// Supabase REST + realtime (WSS). All Meta API calls happen
// server-side, so graph.facebook.com does not belong here.

1040
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -50,12 +50,13 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.4.0",
"lucide-react": "^1.17.0",
"lucide-react": "^1.18.0",
"next": "16.2.6",
"opus-recorder": "^8.0.5",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1",
"shadcn": "^4.10.0",
"shadcn": "^4.11.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
@ -67,16 +68,18 @@
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"prettier": "^3.8.3",
"prettier": "^3.8.4",
"prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^4",
"typescript": "^6",
"vitest": "^4.1.8"
"vitest": "^4.1.9"
},
"overrides": {
"postcss": "^8.5.10",
"ip-address": "^10.1.1",
"fast-uri": "^3.1.2",
"hono": "^4.12.21"
"hono": "^4.12.25",
"js-yaml": "^4.2.0",
"@babel/core": "^7.29.6"
}
}

1
public/opus/encoderWorker.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -43,18 +43,18 @@ export default function ForgotPasswordPage() {
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<CheckCircle className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-white">
<CardTitle className="text-xl text-foreground">
Check your email
</CardTitle>
<CardDescription className="text-slate-400">
<CardDescription className="text-muted-foreground">
We&apos;ve sent a password reset link to{" "}
<span className="text-white">{email}</span>. Please check your
<span className="text-foreground">{email}</span>. Please check your
inbox.
</CardDescription>
</CardHeader>
@ -62,7 +62,7 @@ export default function ForgotPasswordPage() {
<Link href="/login">
<Button
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Back to sign in
</Button>
@ -74,14 +74,14 @@ export default function ForgotPasswordPage() {
}
return (
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<MessageSquare className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-white">Reset password</CardTitle>
<CardDescription className="text-slate-400">
<CardTitle className="text-xl text-foreground">Reset password</CardTitle>
<CardDescription className="text-muted-foreground">
Enter your email and we&apos;ll send you a reset link
</CardDescription>
</CardHeader>
@ -94,7 +94,7 @@ export default function ForgotPasswordPage() {
)}
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-slate-300">
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input
@ -104,7 +104,7 @@ export default function ForgotPasswordPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
@ -119,7 +119,7 @@ export default function ForgotPasswordPage() {
<Link
href="/login"
className="mt-6 flex items-center justify-center gap-2 text-sm text-slate-400 hover:text-slate-300"
className="mt-6 flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to sign in

View File

@ -67,8 +67,8 @@ function LoginPageInner() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
{inviteToken ? (
@ -77,10 +77,10 @@ function LoginPageInner() {
<MessageSquare className="h-6 w-6 text-primary" />
)}
</div>
<CardTitle className="text-xl text-white">
<CardTitle className="text-xl text-foreground">
{inviteToken ? "Sign in to accept" : "Welcome back"}
</CardTitle>
<CardDescription className="text-slate-400">
<CardDescription className="text-muted-foreground">
{inviteToken
? "Sign in and we'll take you to the invitation."
: "Sign in to your account"}
@ -95,7 +95,7 @@ function LoginPageInner() {
)}
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-slate-300">
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input
@ -105,13 +105,13 @@ function LoginPageInner() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password" className="text-slate-300">
<Label htmlFor="password" className="text-muted-foreground">
Password
</Label>
<Link
@ -128,7 +128,7 @@ function LoginPageInner() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
@ -141,7 +141,7 @@ function LoginPageInner() {
</Button>
</form>
<p className="mt-6 text-center text-sm text-slate-400">
<p className="mt-6 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link
href={

View File

@ -91,18 +91,18 @@ function SignupPageInner() {
if (success) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<CheckCircle className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-white">
<CardTitle className="text-xl text-foreground">
Check your email
</CardTitle>
<CardDescription className="text-slate-400">
<CardDescription className="text-muted-foreground">
We&apos;ve sent a confirmation link to{" "}
<span className="text-white">{email}</span>. Please check your
<span className="text-foreground">{email}</span>. Please check your
inbox and click the link to verify your account.
</CardDescription>
</CardHeader>
@ -116,7 +116,7 @@ function SignupPageInner() {
>
<Button
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Back to sign in
</Button>
@ -128,8 +128,8 @@ function SignupPageInner() {
}
return (
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
<div className="flex min-h-screen items-center justify-center bg-background px-4">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
{inviteToken ? (
@ -138,10 +138,10 @@ function SignupPageInner() {
<MessageSquare className="h-6 w-6 text-primary" />
)}
</div>
<CardTitle className="text-xl text-white">
<CardTitle className="text-xl text-foreground">
{inviteToken ? "Create account & join" : "Create account"}
</CardTitle>
<CardDescription className="text-slate-400">
<CardDescription className="text-muted-foreground">
{inviteToken
? "Verify your email, then accept the invitation to join your team."
: "Get started with CRM Template for WhatsApp"}
@ -156,7 +156,7 @@ function SignupPageInner() {
)}
<div className="flex flex-col gap-2">
<Label htmlFor="fullName" className="text-slate-300">
<Label htmlFor="fullName" className="text-muted-foreground">
Full name
</Label>
<Input
@ -166,12 +166,12 @@ function SignupPageInner() {
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-slate-300">
<Label htmlFor="email" className="text-muted-foreground">
Email
</Label>
<Input
@ -181,12 +181,12 @@ function SignupPageInner() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="password" className="text-slate-300">
<Label htmlFor="password" className="text-muted-foreground">
Password
</Label>
<Input
@ -196,12 +196,12 @@ function SignupPageInner() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="confirmPassword" className="text-slate-300">
<Label htmlFor="confirmPassword" className="text-muted-foreground">
Confirm password
</Label>
<Input
@ -211,7 +211,7 @@ function SignupPageInner() {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
/>
</div>
@ -224,7 +224,7 @@ function SignupPageInner() {
</Button>
</form>
<p className="mt-6 text-center text-sm text-slate-400">
<p className="mt-6 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href={

View File

@ -87,21 +87,21 @@ export default function AutomationLogsPage({
<button
type="button"
onClick={() => router.push("/automations")}
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Back"
>
<ArrowLeft className="h-4 w-4" />
</button>
<div>
<h1 className="text-2xl font-bold text-white">{automation.name}</h1>
<p className="mt-0.5 text-sm text-slate-400">Execution logs</p>
<h1 className="text-2xl font-bold text-foreground">{automation.name}</h1>
<p className="mt-0.5 text-sm text-muted-foreground">Execution logs</p>
</div>
</div>
{logs.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-slate-800 bg-slate-900/40">
<p className="text-sm text-white">No executions yet</p>
<p className="mt-1 text-xs text-slate-400">
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40">
<p className="text-sm text-foreground">No executions yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Trigger this automation to see runs here.
</p>
</div>
@ -112,7 +112,7 @@ export default function AutomationLogsPage({
return (
<li
key={log.id}
className="rounded-xl border border-slate-800 bg-slate-900"
className="rounded-xl border border-border bg-card"
>
<button
type="button"
@ -120,26 +120,26 @@ export default function AutomationLogsPage({
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-slate-500" />
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-slate-500" />
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
<StatusBadge status={log.status} />
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-white">
<div className="truncate text-sm font-medium text-foreground">
{log.contact?.name ?? log.contact?.phone ?? "Unknown contact"}
</div>
<div className="truncate text-xs text-slate-500">
<div className="truncate text-xs text-muted-foreground">
{log.trigger_event} · {log.steps_executed?.length ?? 0} step
{log.steps_executed?.length === 1 ? "" : "s"}
</div>
</div>
<div className="text-xs text-slate-500">
<div className="text-xs text-muted-foreground">
{formatRelative(log.created_at)}
</div>
</button>
{isOpen && (
<div className="border-t border-slate-800 px-4 py-3">
<div className="border-t border-border px-4 py-3">
{log.error_message && (
<p className="mb-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{log.error_message}
@ -150,7 +150,7 @@ export default function AutomationLogsPage({
<StepRow key={i} result={r} />
))}
{(log.steps_executed ?? []).length === 0 && (
<li className="text-xs text-slate-500">No steps recorded.</li>
<li className="text-xs text-muted-foreground">No steps recorded.</li>
)}
</ul>
</div>
@ -196,9 +196,9 @@ function StepRow({ result }: { result: AutomationLogStepResult }) {
>
{ok ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
</span>
<span className="text-slate-300">{result.step_type}</span>
<span className="text-muted-foreground">{result.step_type}</span>
{result.detail && (
<span className="truncate text-slate-500">— {result.detail}</span>
<span className="truncate text-muted-foreground">— {result.detail}</span>
)}
</li>
)

View File

@ -160,8 +160,8 @@ export default function AutomationsPage() {
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Automations</h1>
<p className="mt-1 text-sm text-slate-400">
<h1 className="text-2xl font-bold text-foreground">Automations</h1>
<p className="mt-1 text-sm text-muted-foreground">
Build workflows that react to WhatsApp® events automatically.
</p>
</div>
@ -178,7 +178,7 @@ export default function AutomationsPage() {
{showTemplates && (
<section>
<h2 className="mb-3 text-sm font-semibold text-slate-300">Quick-start templates</h2>
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">Quick-start templates</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
{TEMPLATE_ORDER.map((slug) => {
const t = AUTOMATION_TEMPLATES[slug]
@ -187,13 +187,13 @@ export default function AutomationsPage() {
<button
key={slug}
onClick={() => startFromTemplate(slug)}
className="group flex flex-col items-start rounded-xl border border-slate-800 bg-slate-900 p-4 text-left transition-colors hover:border-primary/50 hover:bg-slate-900/80"
className="group flex flex-col items-start rounded-xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
>
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary group-hover:bg-primary/15">
<Icon className="h-5 w-5" />
</div>
<div className="text-sm font-semibold text-white">{t.name}</div>
<p className="mt-1 text-xs text-slate-400">{t.description}</p>
<div className="text-sm font-semibold text-foreground">{t.name}</div>
<p className="mt-1 text-xs text-muted-foreground">{t.description}</p>
</button>
)
})}
@ -202,12 +202,12 @@ export default function AutomationsPage() {
)}
{automations.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-slate-800 bg-slate-900/40">
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<Zap className="h-6 w-6 text-primary" />
</div>
<p className="mt-3 text-sm font-medium text-white">No automations yet</p>
<p className="mt-1 text-xs text-slate-400">
<p className="mt-3 text-sm font-medium text-foreground">No automations yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Pick a template above or create one from scratch.
</p>
</div>
@ -233,7 +233,7 @@ export default function AutomationsPage() {
<DialogTitle>Delete automation</DialogTitle>
<DialogDescription>
This permanently removes{" "}
<span className="text-white">{pendingDelete?.name}</span> and its execution
<span className="text-foreground">{pendingDelete?.name}</span> and its execution
history. This cannot be undone.
</DialogDescription>
</DialogHeader>
@ -277,7 +277,7 @@ function AutomationCard({
}) {
const meta = triggerMeta(automation.trigger_type)
return (
<li className="rounded-xl border border-slate-800 bg-slate-900 transition-colors hover:border-slate-700">
<li className="rounded-xl border border-border bg-card transition-colors hover:border-border">
<div className="flex items-center gap-4 p-4">
<div
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary/10"
@ -292,7 +292,7 @@ function AutomationCard({
className="min-w-0 flex-1 text-left"
>
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-white">
<span className="truncate text-sm font-semibold text-foreground">
{automation.name}
</span>
{automation.is_active && (
@ -303,9 +303,9 @@ function AutomationCard({
)}
</div>
{automation.description && (
<p className="mt-0.5 truncate text-xs text-slate-400">{automation.description}</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">{automation.description}</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span
className={cn(
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
@ -332,7 +332,7 @@ function AutomationCard({
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Open menu"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white data-[popup-open]:bg-slate-800"
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[popup-open]:bg-muted"
>
<MoreVertical className="h-4 w-4" />
</DropdownMenuTrigger>

View File

@ -50,15 +50,15 @@ interface StatCardProps {
function StatCard({ label, value, total, icon, color }: StatCardProps) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<div className="rounded-xl border border-border bg-card p-4">
<div className="flex items-center justify-between">
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${color}`}>
{icon}
</div>
<span className="text-xs text-slate-500">{pct}%</span>
<span className="text-xs text-muted-foreground">{pct}%</span>
</div>
<p className="mt-3 text-2xl font-bold text-white">{value.toLocaleString()}</p>
<p className="text-xs text-slate-400">{label}</p>
<p className="mt-3 text-2xl font-bold text-foreground">{value.toLocaleString()}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
);
}
@ -77,8 +77,8 @@ interface FunnelStep {
function FunnelChart({ steps }: { steps: FunnelStep[] }) {
const max = Math.max(...steps.map((s) => s.value), 1);
return (
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<h3 className="mb-4 text-sm font-medium text-white">Funnel</h3>
<div className="rounded-xl border border-border bg-card p-4">
<h3 className="mb-4 text-sm font-medium text-foreground">Funnel</h3>
<div className="space-y-2">
{steps.map((step) => {
const pctOfMax = Math.max(5, Math.round((step.value / max) * 100));
@ -88,17 +88,17 @@ function FunnelChart({ steps }: { steps: FunnelStep[] }) {
: 0;
return (
<div key={step.label} className="flex items-center gap-3">
<span className="w-20 shrink-0 text-xs text-slate-400">
<span className="w-20 shrink-0 text-xs text-muted-foreground">
{step.label}
</span>
<div className="relative h-7 flex-1 rounded-full bg-slate-800">
<div className="relative h-7 flex-1 rounded-full bg-muted">
<div
className={`h-7 rounded-full ${step.color} transition-[width] duration-500`}
style={{ width: `${pctOfMax}%` }}
/>
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-white">
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-foreground">
{step.value.toLocaleString()}
<span className="ml-2 text-slate-300/80">
<span className="ml-2 text-muted-foreground/80">
({pctOfSent}%)
</span>
</span>
@ -280,20 +280,20 @@ export default function BroadcastDetailPage() {
variant="outline"
size="icon"
onClick={() => router.push('/broadcasts')}
className="border-slate-700"
className="border-border"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-white">{broadcast.name}</h1>
<h1 className="text-2xl font-bold text-foreground">{broadcast.name}</h1>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
>
{status.label}
</span>
</div>
<div className="mt-1 flex items-center gap-3 text-sm text-slate-400">
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
<span>Template: {broadcast.template_name}</span>
<span>-</span>
<span>
@ -315,7 +315,7 @@ export default function BroadcastDetailPage() {
size="sm"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="h-7 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
className="h-7 border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
@ -354,7 +354,7 @@ export default function BroadcastDetailPage() {
value={broadcast.total_recipients}
total={broadcast.total_recipients}
icon={<Users className="h-4 w-4" />}
color="bg-slate-800 text-slate-300"
color="bg-muted text-muted-foreground"
/>
<StatCard
label="Sent"
@ -396,9 +396,9 @@ export default function BroadcastDetailPage() {
<FunnelChart steps={funnelSteps} />
{/* Recipients Table */}
<div className="rounded-xl border border-slate-800 bg-slate-900">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 px-4 py-3">
<h2 className="text-sm font-medium text-white">
<div className="rounded-xl border border-border bg-card">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
<h2 className="text-sm font-medium text-foreground">
Recipients ({filteredRecipients.length}
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
</h2>
@ -409,7 +409,7 @@ export default function BroadcastDetailPage() {
<Button
variant="outline"
size="sm"
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-muted-foreground hover:bg-muted"
/>
}
>
@ -419,11 +419,11 @@ export default function BroadcastDetailPage() {
: getRecipientStatus(statusFilter).label}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent className="border-slate-700 bg-slate-900">
<DropdownMenuContent className="border-border bg-popover">
<DropdownMenuItem
onClick={() => setStatusFilter('all')}
className={
statusFilter === 'all' ? 'text-primary' : 'text-slate-300'
statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground'
}
>
All statuses
@ -435,7 +435,7 @@ export default function BroadcastDetailPage() {
className={
statusFilter === s
? 'text-primary'
: 'text-slate-300'
: 'text-popover-foreground'
}
>
{getRecipientStatus(s).label}
@ -449,7 +449,7 @@ export default function BroadcastDetailPage() {
size="sm"
onClick={handleExport}
disabled={recipients.length === 0}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-muted-foreground hover:bg-muted"
>
<Download className="h-3.5 w-3.5" />
Export CSV
@ -459,7 +459,7 @@ export default function BroadcastDetailPage() {
{filteredRecipients.length === 0 ? (
<div className="flex h-32 items-center justify-center">
<p className="text-sm text-slate-400">
<p className="text-sm text-muted-foreground">
{recipients.length === 0
? 'No recipients found.'
: 'No recipients match this filter.'}
@ -469,25 +469,25 @@ export default function BroadcastDetailPage() {
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Contact</TableHead>
<TableHead className="text-slate-400">Phone</TableHead>
<TableHead className="text-slate-400">Status</TableHead>
<TableHead className="text-slate-400">Sent</TableHead>
<TableHead className="text-slate-400">Delivered</TableHead>
<TableHead className="text-slate-400">Read</TableHead>
<TableHead className="text-slate-400">Error</TableHead>
<TableRow className="border-border hover:bg-transparent">
<TableHead className="text-muted-foreground">Contact</TableHead>
<TableHead className="text-muted-foreground">Phone</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead>
<TableHead className="text-muted-foreground">Sent</TableHead>
<TableHead className="text-muted-foreground">Delivered</TableHead>
<TableHead className="text-muted-foreground">Read</TableHead>
<TableHead className="text-muted-foreground">Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRecipients.map((recipient) => {
const rStatus = getRecipientStatus(recipient.status);
return (
<TableRow key={recipient.id} className="border-slate-800">
<TableCell className="font-medium text-white">
<TableRow key={recipient.id} className="border-border">
<TableCell className="font-medium text-foreground">
{recipient.contact?.name ?? 'Unknown'}
</TableCell>
<TableCell className="text-slate-300">
<TableCell className="text-muted-foreground">
{recipient.contact?.phone ?? '-'}
</TableCell>
<TableCell>
@ -497,17 +497,17 @@ export default function BroadcastDetailPage() {
{rStatus.label}
</span>
</TableCell>
<TableCell className="text-slate-400">
<TableCell className="text-muted-foreground">
{recipient.sent_at
? new Date(recipient.sent_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="text-slate-400">
<TableCell className="text-muted-foreground">
{recipient.delivered_at
? new Date(recipient.delivered_at).toLocaleString()
: '-'}
</TableCell>
<TableCell className="text-slate-400">
<TableCell className="text-muted-foreground">
{recipient.read_at
? new Date(recipient.read_at).toLocaleString()
: '-'}

View File

@ -41,6 +41,7 @@ export default function NewBroadcastPage() {
const [variables, setVariables] = useState<
Record<string, { type: 'static' | 'field' | 'custom_field'; value: string }>
>({});
const [headerMediaUrl, setHeaderMediaUrl] = useState('');
const [name, setName] = useState('');
async function handleSend() {
@ -58,6 +59,7 @@ export default function NewBroadcastPage() {
excludeTagIds: audience.excludeTagIds,
},
variables,
headerMediaUrl,
});
router.push(`/broadcasts/${broadcastId}`);
} catch (err) {
@ -129,8 +131,8 @@ export default function NewBroadcastPage() {
<div className="mx-auto max-w-3xl space-y-8">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">New Broadcast</h1>
<p className="mt-1 text-sm text-slate-400">
<h1 className="text-2xl font-bold text-foreground">New Broadcast</h1>
<p className="mt-1 text-sm text-muted-foreground">
Create and send a broadcast message to your contacts.
</p>
</div>
@ -150,14 +152,14 @@ export default function NewBroadcastPage() {
? 'bg-primary text-primary-foreground'
: isActive
? 'border-2 border-primary bg-primary/10 text-primary'
: 'border border-slate-700 bg-slate-800 text-slate-500'
: 'border border-border bg-muted text-muted-foreground'
}`}
>
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
</div>
<span
className={`hidden text-sm font-medium sm:block ${
isActive ? 'text-white' : isCompleted ? 'text-primary' : 'text-slate-500'
isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground'
}`}
>
{step.label}
@ -166,7 +168,7 @@ export default function NewBroadcastPage() {
{index < steps.length - 1 && (
<div
className={`mx-3 h-px flex-1 ${
index < currentStep ? 'bg-primary' : 'bg-slate-800'
index < currentStep ? 'bg-primary' : 'bg-muted'
}`}
/>
)}
@ -205,6 +207,8 @@ export default function NewBroadcastPage() {
template={template}
variables={variables}
onUpdate={setVariables}
headerMediaUrl={headerMediaUrl}
onHeaderMediaUrlChange={setHeaderMediaUrl}
onNext={() => setCurrentStep(3)}
onBack={() => setCurrentStep(1)}
/>

View File

@ -43,10 +43,10 @@ function RateCell({
const pct = percent(value, total);
return (
<div className="flex items-center gap-2">
<span className="w-10 text-right text-xs tabular-nums text-slate-300">
<span className="w-10 text-right text-xs tabular-nums text-muted-foreground">
{pct}%
</span>
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-slate-800">
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
<div
className={`h-1.5 rounded-full ${color}`}
style={{ width: `${pct}%` }}
@ -155,7 +155,7 @@ export default function BroadcastsPage() {
<div
role="progressbar"
aria-label="Broadcast in progress"
className="broadcast-indeterminate fixed inset-x-0 top-0 z-40 h-0.5 overflow-hidden bg-slate-800"
className="broadcast-indeterminate fixed inset-x-0 top-0 z-40 h-0.5 overflow-hidden bg-muted"
>
<div className="broadcast-indeterminate-bar h-0.5 bg-primary" />
<style jsx>{`
@ -179,8 +179,8 @@ export default function BroadcastsPage() {
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Broadcasts</h1>
<p className="mt-1 text-sm text-slate-400">
<h1 className="text-2xl font-bold text-foreground">Broadcasts</h1>
<p className="mt-1 text-sm text-muted-foreground">
Send bulk messages to your contacts using approved templates.
</p>
</div>
@ -196,10 +196,10 @@ export default function BroadcastsPage() {
</div>
{broadcasts.length === 0 ? (
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900">
<Radio className="mb-3 h-10 w-10 text-slate-600" />
<p className="text-sm font-medium text-white">No broadcasts yet</p>
<p className="mt-1 text-xs text-slate-400">
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-border bg-card">
<Radio className="mb-3 h-10 w-10 text-muted-foreground" />
<p className="text-sm font-medium text-foreground">No broadcasts yet</p>
<p className="mt-1 text-xs text-muted-foreground">
Create your first broadcast to reach your contacts at scale.
</p>
<GatedButton
@ -213,19 +213,19 @@ export default function BroadcastsPage() {
</GatedButton>
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-slate-800 bg-slate-900">
<div className="overflow-x-auto rounded-xl border border-border bg-card">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Name</TableHead>
<TableHead className="hidden text-slate-400 md:table-cell">Template</TableHead>
<TableHead className="hidden text-right text-slate-400 sm:table-cell">
<TableRow className="border-border hover:bg-transparent">
<TableHead className="text-muted-foreground">Name</TableHead>
<TableHead className="hidden text-muted-foreground md:table-cell">Template</TableHead>
<TableHead className="hidden text-right text-muted-foreground sm:table-cell">
Recipients
</TableHead>
<TableHead className="hidden text-slate-400 lg:table-cell">Delivery</TableHead>
<TableHead className="hidden text-slate-400 lg:table-cell">Read</TableHead>
<TableHead className="text-slate-400">Status</TableHead>
<TableHead className="hidden text-slate-400 sm:table-cell">Date</TableHead>
<TableHead className="hidden text-muted-foreground lg:table-cell">Delivery</TableHead>
<TableHead className="hidden text-muted-foreground lg:table-cell">Read</TableHead>
<TableHead className="text-muted-foreground">Status</TableHead>
<TableHead className="hidden text-muted-foreground sm:table-cell">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -234,16 +234,16 @@ export default function BroadcastsPage() {
return (
<TableRow
key={broadcast.id}
className="cursor-pointer border-slate-800 hover:bg-slate-800/50"
className="cursor-pointer border-border hover:bg-muted/50"
onClick={() => router.push(`/broadcasts/${broadcast.id}`)}
>
<TableCell className="font-medium text-white">
<TableCell className="font-medium text-foreground">
{broadcast.name}
</TableCell>
<TableCell className="hidden text-slate-300 md:table-cell">
<TableCell className="hidden text-muted-foreground md:table-cell">
{broadcast.template_name}
</TableCell>
<TableCell className="hidden text-right text-slate-300 tabular-nums sm:table-cell">
<TableCell className="hidden text-right text-muted-foreground tabular-nums sm:table-cell">
{broadcast.total_recipients}
</TableCell>
<TableCell className="hidden lg:table-cell">
@ -273,7 +273,7 @@ export default function BroadcastsPage() {
{status.label}
</span>
</TableCell>
<TableCell className="hidden text-slate-400 sm:table-cell">
<TableCell className="hidden text-muted-foreground sm:table-cell">
{new Date(broadcast.created_at).toLocaleDateString()}
</TableCell>
</TableRow>

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { createClient } from '@/lib/supabase/client';
import { toast } from 'sonner';
import type { Contact, Tag, ContactTag } from '@/types';
@ -29,6 +29,11 @@ import {
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
Search,
Plus,
@ -41,6 +46,8 @@ import {
ChevronLeft,
ChevronRight,
SlidersHorizontal,
Filter,
X,
} from 'lucide-react';
import { ContactForm } from '@/components/contacts/contact-form';
import { ContactDetailView } from '@/components/contacts/contact-detail-view';
@ -48,6 +55,7 @@ import { ImportModal } from '@/components/contacts/import-modal';
import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager';
import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button';
import { Checkbox } from '@/components/ui/checkbox';
const PAGE_SIZE = 25;
@ -65,6 +73,8 @@ export default function ContactsPage() {
const [search, setSearch] = useState('');
const [page, setPage] = useState(0);
const [totalCount, setTotalCount] = useState(0);
// Tag filter — contacts shown must have ANY of these tags (OR).
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
// Modals
const [formOpen, setFormOpen] = useState(false);
@ -78,57 +88,107 @@ export default function ContactsPage() {
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
const [deleting, setDeleting] = useState(false);
// Bulk selection (page-scoped — only the loaded rows are selectable)
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
// All tags for display
const [tagsMap, setTagsMap] = useState<Record<string, Tag>>({});
// Guards against out-of-order fetch responses: each fetchContacts run
// claims a sequence number and only the latest is allowed to commit its
// results. Without this, rapidly toggling tag filters could let a slower
// earlier request resolve last and render stale rows.
const fetchSeq = useRef(0);
const fetchTags = useCallback(async () => {
const { data } = await supabase.from('tags').select('*');
if (data) {
const map: Record<string, Tag> = {};
data.forEach((t) => (map[t.id] = t));
setTagsMap(map);
// Drop any filter selections whose tag no longer exists (e.g. a tag
// deleted elsewhere) so it can't linger invisibly in the query.
setSelectedTagIds((prev) => {
const pruned = prev.filter((id) => map[id]);
return pruned.length === prev.length ? prev : pruned;
});
}
}, [supabase]);
const fetchContacts = useCallback(async () => {
const seq = ++fetchSeq.current;
setLoading(true);
// The visible rows are about to change — drop any selection that
// referred to the old page/search results so the bulk bar can't
// act on rows the user can no longer see.
setSelected(new Set());
const from = page * PAGE_SIZE;
const to = from + PAGE_SIZE - 1;
const term = search.trim();
let contactRows: Contact[];
let count: number;
if (selectedTagIds.length > 0) {
// Tag filter active — resolve it server-side (join + distinct +
// windowed total count + pagination) so a tag covering many
// contacts can't silently truncate the result or overflow an IN
// clause. See migration 025_filter_contacts_by_tags.
const { data, error } = await supabase.rpc('filter_contacts_by_tags', {
p_tag_ids: selectedTagIds,
p_search: term || null,
p_limit: PAGE_SIZE,
p_offset: from,
});
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
if (error) {
toast.error('Failed to load contacts');
setLoading(false);
return;
}
const rows = (data ?? []) as { contact: Contact; total_count: number }[];
contactRows = rows.map((r) => r.contact);
count = rows.length > 0 ? Number(rows[0].total_count) : 0;
} else {
let query = supabase
.from('contacts')
.select('*', { count: 'exact' })
.order('created_at', { ascending: false })
.range(from, to);
if (search.trim()) {
const term = `%${search.trim()}%`;
query = query.or(`name.ilike.${term},phone.ilike.${term},email.ilike.${term}`);
if (term) {
const like = `%${term}%`;
query = query.or(`name.ilike.${like},phone.ilike.${like},email.ilike.${like}`);
}
const { data, count, error } = await query;
const { data, count: exactCount, error } = await query;
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
if (error) {
toast.error('Failed to load contacts');
setLoading(false);
return;
}
contactRows = data ?? [];
count = exactCount ?? 0;
}
setTotalCount(count ?? 0);
setTotalCount(count);
if (!data || data.length === 0) {
if (contactRows.length === 0) {
setContacts([]);
setLoading(false);
return;
}
// Fetch tags for these contacts
const contactIds = data.map((c) => c.id);
const contactIds = contactRows.map((c) => c.id);
const { data: contactTags } = await supabase
.from('contact_tags')
.select('contact_id, tag_id')
.in('contact_id', contactIds);
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
const tagsByContact: Record<string, string[]> = {};
contactTags?.forEach((ct) => {
@ -136,7 +196,7 @@ export default function ContactsPage() {
tagsByContact[ct.contact_id].push(ct.tag_id);
});
const enriched: ContactWithTags[] = data.map((c) => ({
const enriched: ContactWithTags[] = contactRows.map((c) => ({
...c,
tags: (tagsByContact[c.id] ?? [])
.map((tid) => tagsMap[tid])
@ -145,7 +205,7 @@ export default function ContactsPage() {
setContacts(enriched);
setLoading(false);
}, [supabase, page, search, tagsMap]);
}, [supabase, page, search, selectedTagIds, tagsMap]);
// Load-once-on-mount-ish data fetches. Each setter inside runs
// inside an async promise completion (Supabase await), not
@ -208,17 +268,82 @@ export default function ContactsPage() {
setDeleteTarget(null);
}
const allOnPageSelected =
contacts.length > 0 && contacts.every((c) => selected.has(c.id));
const someOnPageSelected = contacts.some((c) => selected.has(c.id));
function toggleSelectAll() {
setSelected((prev) => {
const next = new Set(prev);
if (allOnPageSelected) {
contacts.forEach((c) => next.delete(c.id));
} else {
contacts.forEach((c) => next.add(c.id));
}
return next;
});
}
function toggleSelect(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
async function handleBulkDelete() {
const ids = [...selected];
if (ids.length === 0) return;
setDeleting(true);
const { error } = await supabase.from('contacts').delete().in('id', ids);
if (error) {
toast.error('Failed to delete contacts');
} else {
toast.success(`${ids.length} contact${ids.length === 1 ? '' : 's'} deleted`);
setSelected(new Set());
fetchContacts();
}
setDeleting(false);
setBulkDeleteOpen(false);
}
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
const hasNext = page < totalPages - 1;
const hasPrev = page > 0;
// Tag filter helpers. Every change resets to page 0 — the result set
// shrinks/grows so page N may no longer be valid (mirrors the search box).
const allTags = Object.values(tagsMap).sort((a, b) =>
a.name.localeCompare(b.name)
);
const hasActiveFilters = search.trim().length > 0 || selectedTagIds.length > 0;
function toggleTagFilter(tagId: string) {
setSelectedTagIds((prev) =>
prev.includes(tagId)
? prev.filter((id) => id !== tagId)
: [...prev, tagId]
);
setPage(0);
}
function clearTagFilters() {
setSelectedTagIds([]);
setPage(0);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-white">Contacts</h1>
<p className="text-sm text-slate-400 mt-1">
<h1 className="text-2xl font-bold text-foreground">Contacts</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`}
</p>
</div>
@ -227,7 +352,7 @@ export default function ContactsPage() {
<Button
variant="outline"
onClick={() => setCustomFieldsOpen(true)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-muted-foreground hover:bg-muted"
>
<SlidersHorizontal className="size-4" />
Custom fields
@ -238,7 +363,7 @@ export default function ContactsPage() {
canAct={canEdit}
gateReason="add or import contacts"
onClick={() => setImportOpen(true)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-muted-foreground hover:bg-muted"
>
<Upload className="size-4" />
Import
@ -255,9 +380,11 @@ export default function ContactsPage() {
</div>
</div>
{/* Search */}
<div className="relative max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-slate-500" />
{/* Search + tag filter */}
<div className="space-y-2">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative w-full max-w-sm">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
value={search}
onChange={(e) => {
@ -267,48 +394,187 @@ export default function ContactsPage() {
setPage(0);
}}
placeholder="Search by name, phone, or email..."
className="pl-8 bg-slate-900 border-slate-700 text-white placeholder:text-slate-500"
className="pl-8 bg-card border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<Popover>
<PopoverTrigger
render={
<Button
variant="outline"
className="border-border text-muted-foreground hover:bg-muted shrink-0"
/>
}
>
<Filter className="size-4" />
Filter by tags
{selectedTagIds.length > 0 && (
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground">
{selectedTagIds.length}
</span>
)}
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-0">
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
<span className="text-sm font-medium text-popover-foreground">
Filter by tags
</span>
{selectedTagIds.length > 0 && (
<button
onClick={clearTagFilters}
className="text-xs text-muted-foreground hover:text-foreground"
>
Clear all
</button>
)}
</div>
{allTags.length === 0 ? (
<p className="px-3 py-4 text-sm text-muted-foreground text-center">
No tags yet.
</p>
) : (
<div className="max-h-64 overflow-y-auto py-1">
{allTags.map((tag) => (
<label
key={tag.id}
className="flex items-center gap-2.5 px-3 py-1.5 cursor-pointer hover:bg-muted/50"
>
<Checkbox
checked={selectedTagIds.includes(tag.id)}
onCheckedChange={() => toggleTagFilter(tag.id)}
aria-label={`Filter by ${tag.name}`}
/>
<span
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: tag.color }}
/>
<span className="text-sm text-popover-foreground truncate">
{tag.name}
</span>
</label>
))}
</div>
)}
</PopoverContent>
</Popover>
</div>
{/* Active tag-filter chips */}
{selectedTagIds.length > 0 && (
<div className="flex flex-wrap items-center gap-1.5">
{selectedTagIds.map((id) => {
const tag = tagsMap[id];
if (!tag) return null;
return (
<span
key={id}
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium"
style={{
backgroundColor: tag.color + '20',
color: tag.color,
}}
>
{tag.name}
<button
onClick={() => toggleTagFilter(id)}
aria-label={`Remove ${tag.name} filter`}
className="hover:opacity-70"
>
<X className="size-3" />
</button>
</span>
);
})}
<button
onClick={clearTagFilters}
className="text-xs text-muted-foreground hover:text-foreground px-1"
>
Clear all
</button>
</div>
)}
</div>
{/* Bulk action bar */}
{selected.size > 0 && (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/40 px-4 py-2">
<p className="text-sm text-foreground">
<span className="font-medium">{selected.size}</span>{' '}
{selected.size === 1 ? 'contact' : 'contacts'} selected
</p>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setSelected(new Set())}
className="text-muted-foreground hover:text-foreground"
>
Clear
</Button>
<GatedButton
variant="destructive"
size="sm"
canAct={canEdit}
gateReason="delete contacts"
onClick={() => setBulkDeleteOpen(true)}
>
<Trash2 className="size-4" />
Delete selected
</GatedButton>
</div>
</div>
)}
{/* Table */}
<div className="rounded-lg border border-slate-800 overflow-hidden">
<div className="rounded-lg border border-border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">Name</TableHead>
<TableHead className="text-slate-400">Phone</TableHead>
<TableHead className="text-slate-400 hidden md:table-cell">Email</TableHead>
<TableHead className="text-slate-400 hidden lg:table-cell">Company</TableHead>
<TableHead className="text-slate-400 hidden md:table-cell">Tags</TableHead>
<TableHead className="text-slate-400 hidden lg:table-cell">Created</TableHead>
<TableHead className="text-slate-400 w-12" />
<TableRow className="border-border hover:bg-transparent">
<TableHead className="w-10">
<Checkbox
checked={allOnPageSelected}
indeterminate={!allOnPageSelected && someOnPageSelected}
onCheckedChange={toggleSelectAll}
disabled={contacts.length === 0}
aria-label="Select all contacts on this page"
/>
</TableHead>
<TableHead className="text-muted-foreground">Name</TableHead>
<TableHead className="text-muted-foreground">Phone</TableHead>
<TableHead className="text-muted-foreground hidden md:table-cell">Email</TableHead>
<TableHead className="text-muted-foreground hidden lg:table-cell">Company</TableHead>
<TableHead className="text-muted-foreground hidden md:table-cell">Tags</TableHead>
<TableHead className="text-muted-foreground hidden lg:table-cell">Created</TableHead>
<TableHead className="text-muted-foreground w-12" />
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={7} className="text-center py-12">
<TableRow className="border-border">
<TableCell colSpan={8} className="text-center py-12">
<div className="flex flex-col items-center gap-2">
<Loader2 className="size-6 animate-spin text-primary" />
<p className="text-sm text-slate-500">Loading contacts...</p>
<p className="text-sm text-muted-foreground">Loading contacts...</p>
</div>
</TableCell>
</TableRow>
) : contacts.length === 0 ? (
<TableRow className="border-slate-800">
<TableCell colSpan={7} className="text-center py-12">
<TableRow className="border-border">
<TableCell colSpan={8} className="text-center py-12">
<div className="flex flex-col items-center gap-2">
<Users className="size-8 text-slate-600" />
<p className="text-sm text-slate-500">
{search ? 'No contacts match your search.' : 'No contacts yet.'}
<Users className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{hasActiveFilters
? 'No contacts match your filters.'
: 'No contacts yet.'}
</p>
{!search && (
{!hasActiveFilters && (
<Button
variant="outline"
size="sm"
onClick={openAddForm}
className="mt-2 border-slate-700 text-slate-300 hover:bg-slate-800"
className="mt-2 border-border text-muted-foreground hover:bg-muted"
>
<Plus className="size-3.5" />
Add your first contact
@ -321,20 +587,27 @@ export default function ContactsPage() {
contacts.map((contact) => (
<TableRow
key={contact.id}
className="border-slate-800 hover:bg-slate-900/50 cursor-pointer"
className="border-border hover:bg-muted/50 cursor-pointer"
onClick={() => openDetail(contact.id)}
>
<TableCell className="text-white font-medium">
{contact.name || <span className="text-slate-500 italic">Unnamed</span>}
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selected.has(contact.id)}
onCheckedChange={() => toggleSelect(contact.id)}
aria-label={`Select ${contact.name || contact.phone}`}
/>
</TableCell>
<TableCell className="text-slate-300 font-mono text-xs">
<TableCell className="text-foreground font-medium">
{contact.name || <span className="text-muted-foreground italic">Unnamed</span>}
</TableCell>
<TableCell className="text-muted-foreground font-mono text-xs">
{contact.phone}
</TableCell>
<TableCell className="text-slate-400 hidden md:table-cell text-sm">
{contact.email || <span className="text-slate-600">-</span>}
<TableCell className="text-muted-foreground hidden md:table-cell text-sm">
{contact.email || <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="text-slate-400 hidden lg:table-cell text-sm">
{contact.company || <span className="text-slate-600">-</span>}
<TableCell className="text-muted-foreground hidden lg:table-cell text-sm">
{contact.company || <span className="text-muted-foreground">-</span>}
</TableCell>
<TableCell className="hidden md:table-cell">
<div className="flex flex-wrap gap-1">
@ -352,16 +625,16 @@ export default function ContactsPage() {
</span>
))
) : (
<span className="text-slate-600 text-xs">-</span>
<span className="text-muted-foreground text-xs">-</span>
)}
{contact.tags && contact.tags.length > 3 && (
<span className="text-[10px] text-slate-500">
<span className="text-[10px] text-muted-foreground">
+{contact.tags.length - 3}
</span>
)}
</div>
</TableCell>
<TableCell className="text-slate-500 text-xs hidden lg:table-cell">
<TableCell className="text-muted-foreground text-xs hidden lg:table-cell">
{new Date(contact.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
@ -375,7 +648,7 @@ export default function ContactsPage() {
<Button
variant="ghost"
size="icon-sm"
className="text-slate-400 hover:text-white"
className="text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()}
/>
}
@ -384,19 +657,19 @@ export default function ContactsPage() {
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="bg-slate-900 border-slate-700"
className="bg-popover border-border"
>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
openEditForm(contact);
}}
className="text-slate-300 focus:bg-slate-800 focus:text-white"
className="text-popover-foreground focus:bg-muted focus:text-foreground"
>
<Pencil className="size-4" />
Edit
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
variant="destructive"
onClick={(e) => {
@ -420,7 +693,7 @@ export default function ContactsPage() {
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-xs text-slate-500">
<p className="text-xs text-muted-foreground">
Showing {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalCount)} of{' '}
{totalCount}
</p>
@ -430,11 +703,11 @@ export default function ContactsPage() {
size="icon-sm"
disabled={!hasPrev}
onClick={() => setPage((p) => p - 1)}
className="border-slate-700 text-slate-400 hover:bg-slate-800 hover:text-white disabled:opacity-30"
className="border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
>
<ChevronLeft className="size-4" />
</Button>
<span className="text-xs text-slate-400 px-2">
<span className="text-xs text-muted-foreground px-2">
Page {page + 1} of {totalPages}
</span>
<Button
@ -442,7 +715,7 @@ export default function ContactsPage() {
size="icon-sm"
disabled={!hasNext}
onClick={() => setPage((p) => p + 1)}
className="border-slate-700 text-slate-400 hover:bg-slate-800 hover:text-white disabled:opacity-30"
className="border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
>
<ChevronRight className="size-4" />
</Button>
@ -491,22 +764,22 @@ export default function ContactsPage() {
{/* Delete Confirmation */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-sm">
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-white">Delete Contact</DialogTitle>
<DialogDescription className="text-slate-400">
<DialogTitle className="text-popover-foreground">Delete Contact</DialogTitle>
<DialogDescription className="text-muted-foreground">
Are you sure you want to delete{' '}
<span className="text-slate-200 font-medium">
<span className="text-popover-foreground font-medium">
{deleteTarget?.name || deleteTarget?.phone}
</span>
? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-slate-900 border-slate-700">
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setDeleteConfirmOpen(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
@ -521,6 +794,41 @@ export default function ContactsPage() {
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk Delete Confirmation */}
<Dialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
<DialogHeader>
<DialogTitle className="text-popover-foreground">
Delete {selected.size} {selected.size === 1 ? 'Contact' : 'Contacts'}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Are you sure you want to delete{' '}
<span className="text-popover-foreground font-medium">
{selected.size} {selected.size === 1 ? 'contact' : 'contacts'}
</span>
? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setBulkDeleteOpen(false)}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleBulkDelete}
disabled={deleting}
>
{deleting && <Loader2 className="size-4 animate-spin" />}
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { AuthProvider, useAuth } from "@/hooks/use-auth";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { PresenceHeartbeat } from "@/components/presence/presence-heartbeat";
// Auth-gated dashboard shell. Extracted from the layout so the layout
// itself can stay a server component and export metadata (noindex) —
@ -27,10 +28,10 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
if (loading) {
return (
<div className="flex h-screen items-center justify-center bg-slate-950">
<div className="flex h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-sm text-slate-400">Loading...</p>
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
);
@ -39,7 +40,10 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
if (!user) return null;
return (
<div className="flex h-screen overflow-hidden bg-slate-950">
<div className="flex h-screen overflow-hidden bg-background">
{/* Reports this tab's online/away presence once we know a user is
signed in. Headless — renders nothing. */}
<PresenceHeartbeat />
<Sidebar open={sidebarOpen} onClose={closeSidebar} />
<div className="flex flex-1 flex-col overflow-hidden">
<Header onOpenSidebar={() => setSidebarOpen(true)} />

View File

@ -122,8 +122,8 @@ export default function DashboardPage() {
<div className="space-y-5">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
<p className="mt-1 text-sm text-slate-400">
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
<p className="mt-1 text-sm text-muted-foreground">
Live analytics across conversations, contacts, deals, broadcasts, and automations.
</p>
</div>

View File

@ -65,14 +65,14 @@ export default function FlowEditorPage() {
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (notFound || !flow) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-slate-400">Flow not found.</p>
<p className="text-sm text-muted-foreground">Flow not found.</p>
<button
type="button"
onClick={() => router.push("/flows")}

View File

@ -68,7 +68,7 @@ const STATUS_META: Record<
},
completed: {
label: "Completed",
classes: "border-slate-700 bg-slate-800 text-slate-300",
classes: "border-border bg-muted text-muted-foreground",
icon: CircleCheck,
},
handed_off: {
@ -78,12 +78,12 @@ const STATUS_META: Record<
},
timed_out: {
label: "Timed out",
classes: "border-slate-700 bg-slate-800/60 text-slate-400",
classes: "border-border bg-muted/60 text-muted-foreground",
icon: Clock,
},
paused_by_agent: {
label: "Paused by agent",
classes: "border-slate-700 bg-slate-800 text-slate-300",
classes: "border-border bg-muted text-muted-foreground",
icon: PauseCircle,
},
failed: {
@ -151,14 +151,14 @@ export default function FlowRunsPage() {
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (notFound || !flow) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3">
<p className="text-sm text-slate-400">Flow not found.</p>
<p className="text-sm text-muted-foreground">Flow not found.</p>
<button
type="button"
onClick={() => router.push("/flows")}
@ -175,19 +175,19 @@ export default function FlowRunsPage() {
<button
type="button"
onClick={() => router.push(`/flows/${flow.id}`)}
className="mb-2 inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300"
className="mb-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-3 w-3" />
{flow.name}
</button>
<h1 className="text-xl font-semibold text-white">Runs</h1>
<p className="mt-1 text-sm text-slate-400">
<h1 className="text-xl font-semibold text-foreground">Runs</h1>
<p className="mt-1 text-sm text-muted-foreground">
The 50 most recent times this flow ran. Expand a row to see the engine&apos;s
per-step log.
</p>
{runs.length === 0 ? (
<div className="mt-6 rounded-lg border border-dashed border-slate-700 bg-slate-900/50 px-6 py-12 text-center text-sm text-slate-400">
<div className="mt-6 rounded-lg border border-dashed border-border bg-card/50 px-6 py-12 text-center text-sm text-muted-foreground">
No runs yet. Trigger the flow from a personal WhatsApp number to see
it appear here.
</div>
@ -229,20 +229,20 @@ function RunCard({
})
: null;
return (
<div className="rounded-lg border border-slate-800 bg-slate-900">
<div className="rounded-lg border border-border bg-card">
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
{expanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-slate-500" />
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-slate-500" />
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-white">
<span className="truncate text-sm font-medium text-foreground">
{contactLabel}
</span>
<Badge variant="outline" className={cn("gap-1", meta.classes)}>
@ -250,12 +250,12 @@ function RunCard({
{meta.label}
</Badge>
{run.status === "active" && run.current_node_key && (
<code className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400">
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
at {run.current_node_key}
</code>
)}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span>Started {format(new Date(run.started_at), "PP p")}</span>
{run.reprompt_count > 0 && (
<span>· {run.reprompt_count} re-prompts</span>
@ -265,20 +265,20 @@ function RunCard({
</div>
</button>
{expanded && (
<div className="border-t border-slate-800 px-4 py-3">
<div className="border-t border-border px-4 py-3">
{Object.keys(run.vars).length > 0 && (
<details className="mb-3">
<summary className="cursor-pointer text-xs text-slate-400">
<summary className="cursor-pointer text-xs text-muted-foreground">
Captured vars ({Object.keys(run.vars).length})
</summary>
<pre className="mt-2 overflow-x-auto rounded-md bg-slate-950 p-2 text-[11px] text-slate-300">
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
{JSON.stringify(run.vars, null, 2)}
</pre>
</details>
)}
<div className="flex flex-col gap-1">
{events.length === 0 ? (
<p className="text-xs text-slate-500">
<p className="text-xs text-muted-foreground">
No events recorded for this run.
</p>
) : (
@ -293,33 +293,33 @@ function RunCard({
const EVENT_COLOR: Record<string, string> = {
started: "text-emerald-300",
node_entered: "text-slate-300",
node_entered: "text-muted-foreground",
message_sent: "text-sky-300",
reply_received: "text-primary",
fallback_fired: "text-amber-300",
handoff: "text-amber-300",
timeout: "text-slate-500",
timeout: "text-muted-foreground",
error: "text-red-300",
completed: "text-emerald-300",
};
function EventLine({ ev }: { ev: EventRow }) {
const cls = EVENT_COLOR[ev.event_type] ?? "text-slate-400";
const cls = EVENT_COLOR[ev.event_type] ?? "text-muted-foreground";
return (
<div className="flex items-start gap-2 rounded-md px-2 py-1 text-xs">
<span className="w-32 shrink-0 text-[10px] text-slate-500">
<span className="w-32 shrink-0 text-[10px] text-muted-foreground">
{format(new Date(ev.created_at), "HH:mm:ss")}
</span>
<span className={cn("w-32 shrink-0 font-mono text-[10px]", cls)}>
{ev.event_type}
</span>
{ev.node_key && (
<code className="shrink-0 rounded bg-slate-800 px-1 py-0.5 text-[10px] text-slate-400">
<code className="shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
{ev.node_key}
</code>
)}
{Object.keys(ev.payload).length > 0 && (
<span className="min-w-0 truncate text-[10px] text-slate-500">
<span className="min-w-0 truncate text-[10px] text-muted-foreground">
{summarizePayload(ev.payload)}
</span>
)}

View File

@ -61,9 +61,9 @@ const STATUS_LABELS: Record<FlowRow["status"], string> = {
};
const STATUS_COLORS: Record<FlowRow["status"], string> = {
draft: "border-slate-700 bg-slate-800 text-slate-300",
draft: "border-border bg-muted text-muted-foreground",
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
archived: "border-slate-700 bg-slate-800/50 text-slate-500",
archived: "border-border bg-muted/50 text-muted-foreground",
};
interface TemplateSummary {
@ -194,7 +194,7 @@ export default function FlowsPage() {
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
@ -204,12 +204,12 @@ export default function FlowsPage() {
<header className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-semibold text-white">Flows</h1>
<h1 className="text-2xl font-semibold text-foreground">Flows</h1>
<span className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300">
Beta
</span>
</div>
<p className="mt-1 text-sm text-slate-400">
<p className="mt-1 text-sm text-muted-foreground">
Build branching, button-driven WhatsApp conversations. Useful for
menus, FAQs, and triage before a human steps in.
</p>
@ -247,17 +247,17 @@ export default function FlowsPage() {
`sm:max-w-sm` baked into its default classes. Without the
sm: prefix our override applies at base only and the
sm-scoped 384px wins at every real desktop breakpoint. */}
<DialogContent className="sm:max-w-4xl bg-slate-900 text-slate-100">
<DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground">
<DialogHeader>
<DialogTitle>Create a new flow</DialogTitle>
<DialogDescription className="text-slate-400">
<DialogDescription className="text-muted-foreground">
Start from a template or build from scratch.
</DialogDescription>
</DialogHeader>
{templates.length > 0 && (
<div className="space-y-3">
<p className="text-xs uppercase tracking-wide text-slate-500">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Start from a template
</p>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@ -269,16 +269,16 @@ export default function FlowsPage() {
type="button"
onClick={() => handleUseTemplate(t.slug)}
disabled={creating}
className="flex flex-col gap-2.5 rounded-lg border border-slate-800 bg-slate-950 p-4 text-left transition-colors hover:border-primary/40 hover:bg-slate-800 disabled:opacity-50"
className="flex flex-col gap-2.5 rounded-lg border border-border bg-background p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted disabled:opacity-50"
>
<Icon className="h-5 w-5 text-primary" />
<span className="text-sm font-semibold text-white">
<span className="text-sm font-semibold text-popover-foreground">
{t.name}
</span>
<span className="text-xs leading-relaxed text-slate-400">
<span className="text-xs leading-relaxed text-muted-foreground">
{t.description}
</span>
<span className="mt-auto border-t border-slate-800 pt-2 text-[11px] text-slate-500">
<span className="mt-auto border-t border-border pt-2 text-[11px] text-muted-foreground">
{t.node_count} {t.node_count === 1 ? "node" : "nodes"}
</span>
</button>
@ -288,15 +288,15 @@ export default function FlowsPage() {
</div>
)}
<div className="space-y-2 border-t border-slate-800 pt-4">
<p className="text-xs uppercase tracking-wide text-slate-500">
<div className="space-y-2 border-t border-border pt-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">
Or start blank
</p>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder="e.g. Welcome menu"
className="bg-slate-800"
className="bg-muted"
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
@ -330,14 +330,14 @@ function EmptyState({
canCreate: boolean;
}) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-700 bg-slate-900/50 px-6 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-slate-800">
<Workflow className="h-6 w-6 text-slate-500" />
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card/50 px-6 py-16 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<Workflow className="h-6 w-6 text-muted-foreground" />
</div>
<h2 className="mt-4 text-base font-medium text-white">
<h2 className="mt-4 text-base font-medium text-foreground">
No flows yet
</h2>
<p className="mt-1 max-w-md text-sm text-slate-400">
<p className="mt-1 max-w-md text-sm text-muted-foreground">
Build your first conversation — a welcome menu, an order lookup, an FAQ
bot. Customers tap buttons; the bot routes them to the right answer (or
the right agent).
@ -372,11 +372,11 @@ function FlowCard({
? Archive
: PauseCircle;
return (
<div className="flex flex-col rounded-lg border border-slate-800 bg-slate-900 p-4 transition-colors hover:border-slate-700">
<div className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:border-border">
<div className="flex items-start justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<Workflow className="h-4 w-4 shrink-0 text-primary" />
<h3 className="truncate text-sm font-semibold text-white">
<h3 className="truncate text-sm font-semibold text-foreground">
{flow.name}
</h3>
</div>
@ -392,18 +392,18 @@ function FlowCard({
</Badge>
</div>
<p className="mt-2 line-clamp-2 text-xs text-slate-400">
<p className="mt-2 line-clamp-2 text-xs text-muted-foreground">
{flow.description || triggerSummary}
</p>
<div className="mt-4 flex items-center gap-3 text-[11px] text-slate-500">
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"}
</span>
</div>
<div className="mt-4 flex items-center justify-end gap-2 border-t border-slate-800 pt-3">
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
<Button variant="ghost" size="sm" onClick={onEdit}>
<Pencil className="h-3.5 w-3.5" />
Edit

View File

@ -12,6 +12,10 @@ import { toast } from "sonner";
import { WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
// Remembers the agent's show/hide choice for the desktop contact panel
// across reloads and sessions (device-scoped, like the theme prefs).
const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open";
export default function InboxPage() {
const router = useRouter();
const searchParams = useSearchParams();
@ -39,6 +43,36 @@ export default function InboxPage() {
*/
const [resyncToken, setResyncToken] = useState(0);
/**
* Whether the desktop contact sidebar (tags / deals / notes) is shown.
* Defaults to `true` (the historical behaviour) and is restored from
* localStorage after mount. We deliberately do NOT read localStorage in
* the initializer: the server renders with `true`, so reading a stored
* `false` synchronously would produce a hydration mismatch. The effect
* below reconciles to the stored value right after mount instead.
*/
const [contactPanelOpen, setContactPanelOpen] = useState(true);
useEffect(() => {
try {
const stored = localStorage.getItem(CONTACT_PANEL_STORAGE_KEY);
if (stored !== null) setContactPanelOpen(stored === "true");
} catch {
// localStorage can throw in private-browsing / sandboxed contexts.
}
}, []);
const handleToggleContactPanel = useCallback(() => {
setContactPanelOpen((prev) => {
const next = !prev;
try {
localStorage.setItem(CONTACT_PANEL_STORAGE_KEY, String(next));
} catch {
// Persistence is best-effort; ignore storage failures.
}
return next;
});
}, []);
// Fire the deep-link auto-select exactly once per URL — subsequent
// list refreshes (realtime, manual refetch) must not snap the user
// back to the deep-linked conversation if they've already clicked
@ -570,13 +604,20 @@ export default function InboxPage() {
onBack={handleCloseConversation}
resyncToken={resyncToken}
onRefresh={handleManualRefresh}
contactPanelOpen={contactPanelOpen}
onToggleContactPanel={handleToggleContactPanel}
/>
</div>
{/* Right panel: Contact sidebar — desktop only. */}
{/* Right panel: Contact sidebar — desktop only, and only when the
agent hasn't collapsed it via the thread-header toggle (#258).
On mobile it's always hidden (the `lg:block` below), so the
toggle — which is itself desktop-only — never affects it. */}
{contactPanelOpen && (
<div className="hidden lg:block">
<ContactSidebar contact={activeContact} />
</div>
)}
</div>
</div>
);

View File

@ -299,12 +299,12 @@ export default function PipelinesPage() {
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="h-8 w-48 animate-pulse rounded bg-slate-800" />
<div className="h-9 w-28 animate-pulse rounded-lg bg-slate-800" />
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
<div className="h-9 w-28 animate-pulse rounded-lg bg-muted" />
</div>
<div className="flex gap-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-96 w-72 animate-pulse rounded-xl bg-slate-800/50" />
<div key={i} className="h-96 w-72 animate-pulse rounded-xl bg-muted/50" />
))}
</div>
</div>
@ -319,20 +319,20 @@ export default function PipelinesPage() {
{/* Pipeline selector dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white hover:bg-slate-800 transition-colors data-[popup-open]:bg-slate-800"
className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors data-[popup-open]:bg-muted"
>
<GitBranch className="h-4 w-4 text-primary" />
<span className="font-semibold">
{selectedPipeline?.name ?? "Select Pipeline"}
</span>
<ChevronDown className="h-4 w-4 text-slate-400" />
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-64 border-slate-700 bg-slate-900 text-slate-200"
className="w-64 border-border bg-popover text-popover-foreground"
>
{pipelines.length === 0 && (
<DropdownMenuItem disabled className="text-slate-500">
<DropdownMenuItem disabled className="text-muted-foreground">
No pipelines yet
</DropdownMenuItem>
)}
@ -343,18 +343,18 @@ export default function PipelinesPage() {
className={
p.id === selectedPipelineId
? "text-primary"
: "text-slate-300"
: "text-popover-foreground"
}
>
<GitBranch className="mr-2 h-3.5 w-3.5" />
{p.name}
</DropdownMenuItem>
))}
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuSeparator className="bg-border" />
{selectedPipeline && (
<DropdownMenuItem
onClick={() => setSettingsOpen(true)}
className="text-slate-300"
className="text-popover-foreground"
>
<Settings className="mr-2 h-3.5 w-3.5" />
Manage Pipelines
@ -370,7 +370,7 @@ export default function PipelinesPage() {
canAct={canEditSettings}
gateReason="create pipelines"
onClick={() => setNewPipelineOpen(true)}
className="border-slate-700 bg-slate-900 text-slate-200 hover:bg-slate-800"
className="border-border bg-card text-foreground hover:bg-muted"
>
<Plus className="mr-1 h-4 w-4" />
Add Pipeline
@ -390,12 +390,12 @@ export default function PipelinesPage() {
{/* Board */}
{pipelines.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-700 py-20">
<GitBranch className="h-12 w-12 text-slate-600" />
<h3 className="mt-4 text-lg font-medium text-white">
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20">
<GitBranch className="h-12 w-12 text-muted-foreground" />
<h3 className="mt-4 text-lg font-medium text-foreground">
No pipelines yet
</h3>
<p className="mt-2 text-sm text-slate-400">
<p className="mt-2 text-sm text-muted-foreground">
Create a pipeline to start tracking deals
</p>
<GatedButton
@ -423,30 +423,30 @@ export default function PipelinesPage() {
{/* New Pipeline Dialog */}
<Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}>
<DialogContent className="sm:max-w-sm bg-slate-900 border-slate-700">
<DialogContent className="sm:max-w-sm bg-popover border-border">
<DialogHeader>
<DialogTitle className="text-white">New Pipeline</DialogTitle>
<DialogTitle className="text-popover-foreground">New Pipeline</DialogTitle>
</DialogHeader>
<div className="py-2">
<Label className="text-slate-300">Pipeline Name</Label>
<Label className="text-muted-foreground">Pipeline Name</Label>
<Input
value={newPipelineName}
onChange={(e) => setNewPipelineName(e.target.value)}
placeholder="e.g., Enterprise Sales"
className="mt-2 bg-slate-800 border-slate-700 text-white"
className="mt-2 bg-muted border-border text-foreground"
onKeyDown={(e) => {
if (e.key === "Enter") handleCreatePipeline();
}}
/>
<p className="mt-2 text-xs text-slate-400">
<p className="mt-2 text-xs text-muted-foreground">
Default stages (New Lead → Won) will be created automatically.
</p>
</div>
<DialogFooter className="bg-slate-900/50 border-slate-700">
<DialogFooter className="bg-popover/50 border-border">
<Button
variant="outline"
onClick={() => setNewPipelineOpen(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>

View File

@ -1,179 +1,84 @@
'use client';
import { useMemo, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import {
Settings,
MessageSquare,
Tag,
User,
Palette,
UsersRound,
Coins,
SlidersHorizontal,
} from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useCan } from '@/hooks/use-can';
import { useAuth } from '@/hooks/use-auth';
import { useTheme } from '@/hooks/use-theme';
import { SettingsRail } from '@/components/settings/settings-rail';
import { SettingsOverview } from '@/components/settings/settings-overview';
import { ProfileForm } from '@/components/settings/profile-form';
import { SecurityPanel } from '@/components/settings/security-panel';
import { AppearancePanel } from '@/components/settings/appearance-panel';
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
import { TemplateManager } from '@/components/settings/template-manager';
import { TagManager } from '@/components/settings/tag-manager';
import { ProfileForm } from '@/components/settings/profile-form';
import { PasswordForm } from '@/components/settings/password-form';
import { SessionsCard } from '@/components/settings/sessions-card';
import { AppearancePanel } from '@/components/settings/appearance-panel';
import { MembersTab } from '@/components/settings/members-tab';
import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel';
import { DealsSettings } from '@/components/settings/deals-settings';
import { CustomFieldsSettings } from '@/components/settings/custom-fields-settings';
const TAB_VALUES = [
'profile',
'whatsapp',
'templates',
'tags',
'custom-fields',
'deals',
'appearance',
'members',
] as const;
type TabValue = (typeof TAB_VALUES)[number];
function isTabValue(v: string | null): v is TabValue {
return !!v && (TAB_VALUES as readonly string[]).includes(v);
}
import { MembersTab } from '@/components/settings/members-tab';
import { ApiKeysSettings } from '@/components/settings/api-keys-settings';
import {
resolveSection,
type SettingsSection,
} from '@/components/settings/settings-sections';
export default function SettingsPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { defaultCurrency } = useAuth();
const { mode } = useTheme();
// Custom-field definitions are account-wide config, so editing them is
// admin+ only — mirror the gate on the Contacts page. The `custom_fields`
// RLS rejects non-admin writes regardless.
const canEditSettings = useCan('edit-settings');
// The URL (`?tab=`) is the single source of truth for the active
// section — deep-linkable, and it keeps the existing links in the
// app sidebar/header working. Legacy tab values (tags, custom-fields)
// resolve onto their new home; unknown/empty → the Overview landing.
const section = resolveSection(searchParams.get('tab'));
// The URL is the single source of truth for the active tab — no
// local state, no sync effect. A previous revision duplicated this
// into `useState` + a sync effect, which tripped React 19's
// set-state-in-effect rule and was also redundant.
const queryTab = searchParams.get('tab');
// Deep-linking to the admin-only tab as a non-admin falls back to profile
// rather than landing on a tab with no trigger or content.
const resolved: TabValue = isTabValue(queryTab) ? queryTab : 'profile';
const tab: TabValue =
resolved === 'custom-fields' && !canEditSettings ? 'profile' : resolved;
const onChange = (next: TabValue) => {
const go = (next: SettingsSection) => {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', next);
router.replace(`/settings?${params.toString()}`, { scroll: false });
};
// Cheap, fetch-free rail hints. The Overview landing carries the
// full live status/counts; the rail just surfaces the two that are
// already in context.
const hints: Partial<Record<SettingsSection, ReactNode>> = useMemo(
() => ({
appearance: mode.charAt(0).toUpperCase() + mode.slice(1),
deals: defaultCurrency,
}),
[mode, defaultCurrency],
);
const panel: Record<SettingsSection, ReactNode> = {
overview: <SettingsOverview onSelect={go} />,
profile: <ProfileForm />,
security: <SecurityPanel />,
appearance: <AppearancePanel />,
whatsapp: <WhatsAppConfig />,
templates: <TemplateManager />,
fields: <FieldsAndTagsPanel />,
deals: <DealsSettings />,
members: <MembersTab />,
api: <ApiKeysSettings />,
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white">Settings</h1>
<p className="mt-1 text-sm text-slate-400">
Manage your profile, WhatsApp® integration, message templates, and
tags.
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Settings
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Everything in one place — your account and your workspace. Pick a
section to manage it.
</p>
</div>
<Tabs value={tab} onValueChange={(v) => onChange(v as TabValue)}>
<TabsList className="border border-slate-700 bg-slate-900">
<TabsTrigger
value="profile"
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
>
<User className="size-4" />
Profile
</TabsTrigger>
<TabsTrigger
value="whatsapp"
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
>
<Settings className="size-4" />
WhatsApp Config
</TabsTrigger>
<TabsTrigger
value="templates"
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
>
<MessageSquare className="size-4" />
Templates
</TabsTrigger>
<TabsTrigger
value="tags"
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
>
<Tag className="size-4" />
Tags
</TabsTrigger>
{canEditSettings && (
<TabsTrigger
value="custom-fields"
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
>
<SlidersHorizontal className="size-4" />
Custom Fields
</TabsTrigger>
)}
<TabsTrigger
value="deals"
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
>
<Coins className="size-4" />
Deals
</TabsTrigger>
<TabsTrigger
value="appearance"
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
>
<Palette className="size-4" />
Appearance
</TabsTrigger>
<TabsTrigger
value="members"
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
>
<UsersRound className="size-4" />
Members
</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-6">
<ProfileForm />
<PasswordForm />
<SessionsCard />
</TabsContent>
<TabsContent value="whatsapp">
<WhatsAppConfig />
</TabsContent>
<TabsContent value="templates">
<TemplateManager />
</TabsContent>
<TabsContent value="tags">
<TagManager />
</TabsContent>
{canEditSettings && (
<TabsContent value="custom-fields">
<CustomFieldsSettings />
</TabsContent>
)}
<TabsContent value="deals">
<DealsSettings />
</TabsContent>
<TabsContent value="appearance">
<AppearancePanel />
</TabsContent>
<TabsContent value="members">
<MembersTab />
</TabsContent>
</Tabs>
<div className="mt-6 grid gap-6 lg:grid-cols-[236px_minmax(0,1fr)] lg:items-start">
<SettingsRail active={section} onSelect={go} hints={hints} />
<div className="min-w-0">{panel[section]}</div>
</div>
</div>
);
}

View File

@ -0,0 +1,71 @@
// ============================================================
// DELETE /api/account/api-keys/[id] — revoke a key.
//
// Soft revoke: sets `revoked_at` rather than deleting the row, so
// the key's name/prefix stay visible in the roster as an audit
// trail ("this key existed and was turned off") and so the auth
// path's liveness check (`findActiveKeyByHash` filters revoked
// rows) starts rejecting it immediately. Admin+, enforced here and
// by the `api_keys_update` RLS policy.
//
// Revocation is effective on the next request: once `revoked_at` is
// set, `findActiveKeyByHash` returns null and the key 401s.
// ============================================================
import { NextResponse } from 'next/server';
import { requireRole, toErrorResponse } from '@/lib/auth/account';
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit';
export async function DELETE(
_request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireRole('admin');
const limit = checkRateLimit(
`admin:apiKeyRevoke:${ctx.userId}`,
RATE_LIMITS.adminAction
);
if (!limit.success) return rateLimitResponse(limit);
const { id } = await params;
// Scope the update by account_id as well as id so an admin can
// never revoke another account's key by guessing a UUID. (RLS
// already enforces this; the explicit filter is belt-and-braces
// and makes the "0 rows updated → 404" path precise.)
const { data, error } = await ctx.supabase
.from('api_keys')
.update({ revoked_at: new Date().toISOString() })
.eq('id', id)
.eq('account_id', ctx.accountId)
.is('revoked_at', null)
.select('id')
.maybeSingle();
if (error) {
console.error('[DELETE /api/account/api-keys/[id]] error:', error);
return NextResponse.json(
{ error: 'Failed to revoke API key' },
{ status: 500 }
);
}
if (!data) {
// Either no such key in this account, or it was already revoked.
return NextResponse.json(
{ error: 'API key not found or already revoked' },
{ status: 404 }
);
}
return NextResponse.json({ success: true });
} catch (err) {
return toErrorResponse(err);
}
}

View File

@ -0,0 +1,159 @@
// ============================================================
// /api/account/api-keys
//
// GET — list this account's API keys (safe columns only).
// POST — mint a new key.
//
// These are the *dashboard* endpoints for managing keys, so they
// authenticate the normal way (cookie session) and go through the
// RLS client. Listing is open to any member (viewer+) — the roster
// is not secret; the secret (the key itself) is never in it. Minting
// is admin+ (a key hands out capabilities), enforced by both
// `requireRole('admin')` here and the `api_keys_insert` RLS policy.
//
// IMPORTANT: the plaintext key is returned exactly ONCE, in the POST
// response. We persist only its SHA-256 hash, so neither GET nor any
// future endpoint can resurface it — same one-time-reveal contract
// as invite links. If the admin loses it, they revoke and re-issue.
// ============================================================
import { NextResponse } from 'next/server';
import {
getCurrentAccount,
requireRole,
toErrorResponse,
} from '@/lib/auth/account';
import { generateApiKey } from '@/lib/api-keys/keys';
import { normalizeScopes } from '@/lib/api-keys/scopes';
import {
checkRateLimit,
rateLimitResponse,
RATE_LIMITS,
} from '@/lib/rate-limit';
const MAX_NAME_LEN = 80;
// Hard ceiling on caller-supplied expiry (1 year), mirroring the
// invite-link clamp. NULL/absent = never expires.
const MAX_EXPIRY_DAYS = 365;
// Columns safe to expose. `key_hash` is deliberately excluded — it
// never leaves the server.
const SAFE_COLUMNS =
'id, name, key_prefix, scopes, last_used_at, expires_at, revoked_at, created_at';
export async function GET() {
try {
// Any member can view the roster (RLS allows it); we just need a
// resolved account context.
const ctx = await getCurrentAccount();
const { data, error } = await ctx.supabase
.from('api_keys')
.select(SAFE_COLUMNS)
.eq('account_id', ctx.accountId)
.order('created_at', { ascending: false });
if (error) {
console.error('[GET /api/account/api-keys] fetch error:', error);
return NextResponse.json(
{ error: 'Failed to load API keys' },
{ status: 500 }
);
}
return NextResponse.json({ keys: data ?? [] });
} catch (err) {
return toErrorResponse(err);
}
}
export async function POST(request: Request) {
try {
const ctx = await requireRole('admin');
const limit = checkRateLimit(
`admin:apiKeyCreate:${ctx.userId}`,
RATE_LIMITS.adminAction
);
if (!limit.success) return rateLimitResponse(limit);
const body = (await request.json().catch(() => null)) as {
name?: unknown;
scopes?: unknown;
expiresInDays?: unknown;
} | null;
const rawName = typeof body?.name === 'string' ? body.name.trim() : '';
if (!rawName) {
return NextResponse.json(
{ error: "'name' is required" },
{ status: 400 }
);
}
if (rawName.length > MAX_NAME_LEN) {
return NextResponse.json(
{ error: `Name must be ${MAX_NAME_LEN} characters or fewer` },
{ status: 400 }
);
}
// Scopes default to none if omitted — that yields a key that can
// only call the scope-free endpoints (e.g. GET /api/v1/me).
const scopes = normalizeScopes(body?.scopes ?? []);
if (scopes === null) {
return NextResponse.json(
{ error: "'scopes' must be an array of known scope strings" },
{ status: 400 }
);
}
let expiresAt: string | null = null;
const rawExpiry = body?.expiresInDays;
if (
typeof rawExpiry === 'number' &&
Number.isFinite(rawExpiry) &&
rawExpiry > 0
) {
const days = Math.min(Math.floor(rawExpiry), MAX_EXPIRY_DAYS);
expiresAt = new Date(
Date.now() + days * 24 * 60 * 60 * 1000
).toISOString();
}
const { plaintext, hash, prefix } = generateApiKey();
const { data, error } = await ctx.supabase
.from('api_keys')
.insert({
account_id: ctx.accountId,
created_by: ctx.userId,
name: rawName,
key_prefix: prefix,
key_hash: hash,
scopes,
expires_at: expiresAt,
})
.select(SAFE_COLUMNS)
.single();
if (error || !data) {
console.error('[POST /api/account/api-keys] insert error:', error);
return NextResponse.json(
{ error: 'Failed to create API key' },
{ status: 500 }
);
}
return NextResponse.json(
{
key: data,
// Plaintext — shown to the admin exactly once.
plaintext,
},
{ status: 201 }
);
} catch (err) {
return toErrorResponse(err);
}
}

View File

@ -0,0 +1,31 @@
// ============================================================
// GET /api/v1/me — public API identity probe.
//
// The reference endpoint for the public API: it requires nothing
// but a valid key (no scope), and returns the account the key is
// bound to plus the scopes it carries. Integrators use it to verify
// their key works and to discover what it's allowed to do before
// wiring up real calls.
//
// It also exercises the entire public-API stack end to end — bearer
// parse → hash lookup → liveness → rate limit → envelope — so a
// green response here means the plumbing every future endpoint
// depends on is sound.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { getAccountName } from '@/lib/api-keys/store';
import { ok, toApiErrorResponse } from '@/lib/api/v1/respond';
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request);
const name = await getAccountName(ctx.accountId);
return ok({
account: { id: ctx.accountId, name },
key: { id: ctx.keyId, scopes: ctx.scopes },
});
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@ -0,0 +1,246 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ---------------------------------------------------------------------------
// Tests for the `contact_id` send path (issue #296): sending an approved
// template to a single contact from the Contact detail view. The route must
// find-or-create the contact's conversation server-side, then run the normal
// send + persistence path — no inbound message required to bootstrap a thread.
// ---------------------------------------------------------------------------
// Records of what the route wrote, so we can assert the right rows landed.
const conversationInserts: Array<Record<string, unknown>> = []
const messageInserts: Array<Record<string, unknown>> = []
// Toggles for the per-test scenario.
let existingConversation: Record<string, unknown> | null = null
let contactRow: Record<string, unknown> | null = null
const CONTACT = {
id: 'contact-1',
account_id: 'acct-1',
phone: '+15551234567',
}
// Chainable Supabase mock. A fresh builder per `.from()` call tracks whether
// `.insert()` ran so the terminal resolves to the inserted row for creates
// and the canned select row otherwise.
function makeSupabaseMock() {
function builder(table: string) {
let didInsert = false
const selectResult = () => {
switch (table) {
case 'profiles':
return { data: { account_id: 'acct-1' }, error: null }
case 'contacts':
return { data: contactRow, error: null }
case 'conversations':
return { data: existingConversation, error: null }
case 'whatsapp_config':
return {
data: {
id: 'cfg-1',
account_id: 'acct-1',
phone_number_id: 'PNID-1',
access_token: 'enc-token',
},
error: null,
}
case 'message_templates':
return { data: null, error: null }
default:
return { data: null, error: null }
}
}
const insertResult = () => {
switch (table) {
case 'conversations':
return {
data: {
id: 'conv-new',
account_id: 'acct-1',
contact_id: 'contact-1',
contact: CONTACT,
},
error: null,
}
case 'messages':
return { data: { id: 'msg-1' }, error: null }
default:
return { data: null, error: null }
}
}
const terminal = () =>
Promise.resolve(didInsert ? insertResult() : selectResult())
const b: Record<string, unknown> = {}
const chain = () => b
for (const m of ['select', 'eq', 'in', 'order', 'limit', 'update', 'delete']) {
b[m] = vi.fn(chain)
}
b.insert = vi.fn((payload: Record<string, unknown>) => {
didInsert = true
if (table === 'conversations') conversationInserts.push(payload)
if (table === 'messages') messageInserts.push(payload)
return b
})
b.single = vi.fn(terminal)
b.maybeSingle = vi.fn(terminal)
b.then = (resolve: (v: unknown) => unknown) =>
resolve(didInsert ? insertResult() : selectResult())
return b
}
return {
auth: {
getUser: vi.fn(async () => ({
data: { user: { id: 'user-1' } },
error: null,
})),
},
from: vi.fn((table: string) => builder(table)),
}
}
let supabaseMock = makeSupabaseMock()
vi.mock('@/lib/supabase/server', () => ({
createClient: vi.fn(async () => supabaseMock),
}))
vi.mock('@/lib/flows/admin-client', () => ({
supabaseAdmin: () => ({
from: () => {
const b: Record<string, unknown> = {}
const chain = () => b
for (const m of ['update', 'eq', 'select']) b[m] = vi.fn(chain)
b.then = (resolve: (v: unknown) => unknown) =>
resolve({ data: null, error: null })
return b
},
}),
}))
vi.mock('@/lib/whatsapp/encryption', () => ({
decrypt: vi.fn(() => 'plaintext-token'),
encrypt: vi.fn(() => 'enc-token'),
isLegacyFormat: vi.fn(() => false),
}))
const { sendTemplateMessage } = vi.hoisted(() => ({
sendTemplateMessage: vi.fn(async () => ({ messageId: 'wamid-1' })),
}))
vi.mock('@/lib/whatsapp/meta-api', () => ({
sendTemplateMessage,
sendTextMessage: vi.fn(),
sendMediaMessage: vi.fn(),
}))
import { POST } from './route'
function postContactTemplate(overrides: Record<string, unknown> = {}) {
return POST(
new Request('http://localhost/api/whatsapp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contact_id: 'contact-1',
message_type: 'template',
template_name: 'order_update',
template_language: 'en_US',
template_message_params: { body: ['Acme', '#1234'] },
template_params: ['Acme', '#1234'],
...overrides,
}),
}),
)
}
describe('POST /api/whatsapp/send — contact_id template path', () => {
beforeEach(() => {
conversationInserts.length = 0
messageInserts.length = 0
existingConversation = null
contactRow = CONTACT
supabaseMock = makeSupabaseMock()
sendTemplateMessage.mockClear()
})
afterEach(() => {
vi.clearAllMocks()
})
it('creates a conversation for a contact with none, then sends the template', async () => {
const res = await postContactTemplate()
const json = await res.json()
expect(res.status).toBe(200)
expect(json.success).toBe(true)
expect(json.whatsapp_message_id).toBe('wamid-1')
// A conversation was created for this contact.
expect(conversationInserts).toHaveLength(1)
expect(conversationInserts[0]).toMatchObject({
account_id: 'acct-1',
contact_id: 'contact-1',
})
// The template was sent to the contact's number.
expect(sendTemplateMessage).toHaveBeenCalledTimes(1)
const args = (sendTemplateMessage.mock.calls[0] as unknown[])[0] as Record<
string,
unknown
>
// Meta wants the bare E.164 digits — sanitizePhoneForMeta strips the '+'.
expect(args.to).toBe('15551234567')
expect(args.templateName).toBe('order_update')
// The outbound message was persisted under the new conversation.
expect(messageInserts).toHaveLength(1)
expect(messageInserts[0]).toMatchObject({
conversation_id: 'conv-new',
content_type: 'template',
template_name: 'order_update',
sender_type: 'agent',
})
})
it('reuses an existing conversation instead of creating a duplicate', async () => {
existingConversation = {
id: 'conv-existing',
account_id: 'acct-1',
contact_id: 'contact-1',
contact: CONTACT,
}
const res = await postContactTemplate()
expect(res.status).toBe(200)
expect(conversationInserts).toHaveLength(0)
expect(messageInserts[0]).toMatchObject({ conversation_id: 'conv-existing' })
})
it('404s when the contact is not in the caller account', async () => {
contactRow = null
const res = await postContactTemplate()
const json = await res.json()
expect(res.status).toBe(404)
expect(json.error).toMatch(/contact not found/i)
expect(sendTemplateMessage).not.toHaveBeenCalled()
})
it('400s when neither conversation_id nor contact_id is provided', async () => {
const res = await POST(
new Request('http://localhost/api/whatsapp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message_type: 'template', template_name: 'x' }),
}),
)
expect(res.status).toBe(400)
})
})

View File

@ -1,6 +1,11 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
import {
sendTextMessage,
sendTemplateMessage,
sendMediaMessage,
type MediaKind,
} from '@/lib/whatsapp/meta-api'
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
import { supabaseAdmin } from '@/lib/flows/admin-client'
import {
@ -59,10 +64,15 @@ export async function POST(request: Request) {
const body = await request.json()
const {
conversation_id,
// `conversation_id` targets an existing thread (inbox). `contact_id`
// lets a caller initiate from a contact that may have no conversation
// yet (Contact detail → Send template) — we find-or-create one below.
conversation_id: conversationIdInput,
contact_id,
message_type,
content_text,
media_url,
filename,
template_name,
template_language,
template_params,
@ -70,9 +80,27 @@ export async function POST(request: Request) {
reply_to_message_id,
} = body
if (!conversation_id || !message_type) {
if ((!conversationIdInput && !contact_id) || !message_type) {
return NextResponse.json(
{ error: 'conversation_id and message_type are required' },
{
error:
'Either conversation_id or contact_id, plus message_type, are required',
},
{ status: 400 }
)
}
// Media kinds (image/video/document/audio) are sent to Meta via a
// public URL the composer already uploaded to the chat-media bucket.
const MEDIA_KINDS = ['image', 'video', 'document', 'audio'] as const
const isMediaKind = (MEDIA_KINDS as readonly string[]).includes(message_type)
// Reject anything outside the known set up front rather than letting
// an unknown type fall through to the text path with empty content.
const VALID_MESSAGE_TYPES = ['text', 'template', ...MEDIA_KINDS] as const
if (!(VALID_MESSAGE_TYPES as readonly string[]).includes(message_type)) {
return NextResponse.json(
{ error: `Unsupported message_type "${message_type}"` },
{ status: 400 }
)
}
@ -91,20 +119,89 @@ export async function POST(request: Request) {
)
}
// Fetch conversation and contact
const { data: conversation, error: convError } = await supabase
if (isMediaKind && !media_url) {
return NextResponse.json(
{ error: `media_url is required for ${message_type} messages` },
{ status: 400 }
)
}
// Meta caps media captions at 1024 chars; reject before the upload is
// wasted at the Meta call. (Audio carries no caption — see meta-api.)
if (
isMediaKind &&
message_type !== 'audio' &&
typeof content_text === 'string' &&
content_text.length > 1024
) {
return NextResponse.json(
{ error: 'Caption exceeds the 1024-character limit' },
{ status: 400 }
)
}
// Resolve the target conversation. With `conversation_id` we load the
// existing thread; with `contact_id` we find-or-create one for the
// contact so a business-initiated template send (Contact detail view)
// reuses this whole path — phone variants, send-builder, persistence.
let conversation: { id: string; contact?: { id: string; phone?: string } | null } | null = null
if (conversationIdInput) {
const { data, error: convError } = await supabase
.from('conversations')
.select('*, contact:contacts(*)')
.eq('id', conversation_id)
.eq('id', conversationIdInput)
.eq('account_id', accountId)
.single()
if (convError || !conversation) {
if (convError || !data) {
return NextResponse.json(
{ error: 'Conversation not found' },
{ status: 404 }
)
}
conversation = data
} else {
// contact_id path: verify the contact is in this account first so a
// caller can't open a conversation against someone else's contact.
const { data: contactRow, error: contactErr } = await supabase
.from('contacts')
.select('*')
.eq('id', contact_id)
.eq('account_id', accountId)
.maybeSingle()
if (contactErr || !contactRow) {
return NextResponse.json(
{ error: 'Contact not found' },
{ status: 404 }
)
}
const resolved = await findOrCreateConversation(
supabase,
accountId,
user.id,
contact_id
)
if (!resolved) {
return NextResponse.json(
{ error: 'Failed to open a conversation for this contact' },
{ status: 500 }
)
}
// The embed may not round-trip on insert; pin the contact we verified.
conversation = { ...resolved, contact: resolved.contact ?? contactRow }
}
if (!conversation) {
return NextResponse.json(
{ error: 'Conversation not found' },
{ status: 404 }
)
}
const conversation_id = conversation.id
const contact = conversation.contact
if (!contact?.phone) {
@ -246,6 +343,22 @@ export async function POST(request: Request) {
})
return result.messageId
}
if (isMediaKind) {
// content_text doubles as the caption (ignored for audio inside
// sendMediaMessage). filename surfaces in the recipient's chat
// for documents only.
const result = await sendMediaMessage({
phoneNumberId: config.phone_number_id,
accessToken,
to: phone,
kind: message_type as MediaKind,
link: media_url,
caption: content_text || undefined,
filename: filename || undefined,
contextMessageId,
})
return result.messageId
}
const result = await sendTextMessage({
phoneNumberId: config.phone_number_id,
accessToken,
@ -384,3 +497,45 @@ export async function POST(request: Request) {
)
}
}
type SendSupabase = Awaited<ReturnType<typeof createClient>>
/**
* Return the contact's conversation in this account, creating one if it
* doesn't exist yet. Mirrors the webhook's find-or-create so an
* inbound-then-outbound (or outbound-first) sequence converges on a single
* thread per contact. Runs under the caller's RLS — the conversations_insert
* policy requires account agent membership, which the caller already is.
*/
async function findOrCreateConversation(
supabase: SendSupabase,
accountId: string,
userId: string,
contactId: string,
) {
const { data: existing } = await supabase
.from('conversations')
.select('*, contact:contacts(*)')
.eq('account_id', accountId)
.eq('contact_id', contactId)
.maybeSingle()
if (existing) return existing
const { data: created, error } = await supabase
.from('conversations')
.insert({
account_id: accountId,
user_id: userId,
contact_id: contactId,
})
.select('*, contact:contacts(*)')
.single()
if (error) {
console.error('Error creating conversation for contact send:', error.message)
return null
}
return created
}

View File

@ -10,6 +10,7 @@ import {
type TemplatePayload,
} from '@/lib/whatsapp/template-validators'
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
/**
* Per-template lifecycle endpoint.
@ -136,8 +137,6 @@ export async function PATCH(
)
}
const metaPayload = buildMetaTemplatePayload(payload)
if (!isDryRun()) {
const { data: config, error: configError } = await supabase
.from('whatsapp_config')
@ -151,6 +150,19 @@ export async function PATCH(
)
}
const accessToken = decrypt(config.access_token)
// Image headers need a fresh Resumable-Upload handle on every edit
// (Meta replaces components wholesale). Derive from header_media_url.
try {
await ensureImageHeaderHandle(payload, accessToken)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Header image upload failed.' },
{ status: 400 },
)
}
const metaPayload = buildMetaTemplatePayload(payload)
try {
await editMessageTemplate({
metaTemplateId: existing.meta_template_id,

View File

@ -8,6 +8,7 @@ import {
type TemplatePayload,
} from '@/lib/whatsapp/template-validators'
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
/**
@ -137,8 +138,6 @@ export async function POST(request: Request) {
)
}
const metaPayload = buildMetaTemplatePayload(payload)
const dryRun =
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
@ -175,6 +174,21 @@ export async function POST(request: Request) {
}
const accessToken = decrypt(config.access_token)
// Image headers need a Resumable-Upload handle (Meta rejects a
// plain URL at creation). Derive it from header_media_url before
// building the payload. Surfaces a 400 with an actionable message
// (missing META_APP_ID, unreachable URL, wrong type/size).
try {
await ensureImageHeaderHandle(payload, accessToken)
} catch (e) {
return NextResponse.json(
{ error: e instanceof Error ? e.message : 'Header image upload failed.' },
{ status: 400 },
)
}
const metaPayload = buildMetaTemplatePayload(payload)
try {
const meta = await submitMessageTemplate({
wabaId: config.waba_id,

View File

@ -1,4 +1,4 @@
import { NextResponse } from 'next/server'
import { NextResponse, after } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
@ -12,6 +12,12 @@ import {
isTemplateWebhookField,
} from '@/lib/whatsapp/template-webhook'
// The `after()` callback in POST runs within this route's max duration.
// Inbound processing can fan out to per-media Meta verification calls, so
// give it headroom beyond the platform default (Vercel clamps this to the
// plan's ceiling). Tune as needed.
export const maxDuration = 60
// Lazy-initialized to avoid build-time crash when env vars are missing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let _adminClient: any = null
@ -182,9 +188,26 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
}
// Process asynchronously so we can ack Meta within their timeout.
processWebhook(body).catch((error) => {
// Process AFTER the response so we ack Meta within their ~20s timeout
// (a slow ack triggers Meta retries + duplicate inserts), while still
// guaranteeing the work runs to completion.
//
// This MUST use `after()` rather than a detached `processWebhook(body)`
// promise: on serverless platforms (we run on Vercel) the function can
// be frozen or terminated the moment the response is sent, so a floating
// promise's DB writes are not guaranteed to finish. That dropped a
// non-deterministic *subset* of inbound messages — contacts/conversations
// were created but the message insert never landed, leaving conversations
// that show in the inbox with an empty thread, and no logs to explain it
// (see issue #301). `after()` hands the callback to the runtime, which
// keeps the function alive until it resolves (within the route's
// maxDuration).
after(async () => {
try {
await processWebhook(body)
} catch (error) {
console.error('Error processing webhook:', error)
}
})
return NextResponse.json({ status: 'received' }, { status: 200 })

View File

@ -39,6 +39,15 @@
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
/* Secondary card surface (tile / hover backgrounds) and the accent
* extras the Settings redesign leans on. The raw --primary-* vars
* already exist per-accent below; these mappings expose them (and
* --card-2) as Tailwind utilities: bg-card-2, bg-primary-soft,
* bg-primary-soft-2, hover:bg-primary-hover. */
--color-card-2: var(--card-2);
--color-primary-hover: var(--primary-hover);
--color-primary-soft: var(--primary-soft);
--color-primary-soft-2: var(--primary-soft-2);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@ -49,39 +58,43 @@
}
/* ============================================================
* COLOR THEMES
* THEMING — two orthogonal dimensions
*
* 5 dark themes sharing structure, swapping only the primary
* accent (and a tiny hue rotation on the slate base). Switched
* at runtime by setting `document.documentElement.dataset.theme`
* — see ThemeProvider + the inline boot script in layout.tsx.
* 1. MODE (light / dark) → neutral surfaces. Selected with
* `document.documentElement.dataset.mode`. Defaults to dark.
* 2. ACCENT (violet / …) → the primary color only. Selected
* with `document.documentElement.dataset.theme`.
*
* `:root` mirrors Violet so default visitors render correctly
* before JS runs. `[data-theme="violet"]` is included for the
* explicit-select path (settings UI sets it after a pick).
* The two compose: any accent works in either mode. Neutral
* tokens (background / card / border / muted / sidebar surfaces /
* neutral charts / radius) live in the MODE blocks below; accent
* tokens (--primary*, --ring, --chart-1, --sidebar-primary*,
* --sidebar-ring) live in the ACCENT blocks. They set disjoint
* variables, so cascade order between them doesn't matter.
*
* The three `--primary-*` extras (hover / soft / soft-2) are
* new — used for hovered primary buttons and tinted-primary
* surfaces (sidebar active pill, validation badges). Components
* that don't use them remain backward-compatible.
* `:root` carries the dark-mode + violet defaults so a visitor
* renders correctly before JS runs; the boot script in
* layout.tsx replays the saved mode + accent before first paint.
*
* The three `--primary-*` extras (hover / soft / soft-2) feed
* hovered primary buttons and tinted-primary surfaces (sidebar
* active pill, validation badges).
*
* Token shapes come straight from the design handoff at
* project/Color Themes.html — change one, change the other.
* ============================================================ */
/* ---- MODE: neutral surfaces ---- */
:root,
html[data-theme="violet"] {
html[data-mode="dark"] {
--background: oklch(0.13 0.01 260);
--foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.01 260);
--card-2: oklch(0.205 0.01 260);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0.01 260);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.526 0.247 293);
--primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.6 0.22 293);
--primary-soft: oklch(0.526 0.247 293 / 0.12);
--primary-soft-2: oklch(0.526 0.247 293 / 0.2);
--secondary: oklch(0.22 0.01 260);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.22 0.01 260);
@ -91,8 +104,6 @@ html[data-theme="violet"] {
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.28 0.01 260);
--input: oklch(0.28 0.01 260);
--ring: oklch(0.526 0.247 293);
--chart-1: oklch(0.526 0.247 293);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
@ -100,159 +111,109 @@ html[data-theme="violet"] {
--radius: 0.625rem;
--sidebar: oklch(0.16 0.01 260);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.526 0.247 293);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.22 0.01 260);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.28 0.01 260);
}
html[data-mode="light"] {
--background: oklch(0.99 0.002 260);
--foreground: oklch(0.21 0.01 260);
--card: oklch(1 0 0);
--card-2: oklch(0.985 0.002 260);
--card-foreground: oklch(0.21 0.01 260);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.21 0.01 260);
--secondary: oklch(0.967 0.003 260);
--secondary-foreground: oklch(0.25 0.01 260);
--muted: oklch(0.967 0.003 260);
--muted-foreground: oklch(0.52 0.015 260);
--accent: oklch(0.96 0.004 260);
--accent-foreground: oklch(0.25 0.01 260);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0.004 260);
--input: oklch(0.922 0.004 260);
--chart-2: oklch(0.6 0.1 260);
--chart-3: oklch(0.7 0.06 260);
--chart-4: oklch(0.8 0.04 260);
--chart-5: oklch(0.87 0.03 260);
--radius: 0.625rem;
--sidebar: oklch(0.985 0.002 260);
--sidebar-foreground: oklch(0.21 0.01 260);
--sidebar-accent: oklch(0.96 0.004 260);
--sidebar-accent-foreground: oklch(0.25 0.01 260);
--sidebar-border: oklch(0.922 0.004 260);
}
/* ---- ACCENT: primary color ---- */
:root,
html[data-theme="violet"] {
--primary: oklch(0.526 0.247 293);
--primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.6 0.22 293);
--primary-soft: oklch(0.526 0.247 293 / 0.12);
--primary-soft-2: oklch(0.526 0.247 293 / 0.2);
--ring: oklch(0.526 0.247 293);
--chart-1: oklch(0.526 0.247 293);
--sidebar-primary: oklch(0.526 0.247 293);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-ring: oklch(0.526 0.247 293);
}
html[data-theme="emerald"] {
--background: oklch(0.135 0.01 200);
--foreground: oklch(0.985 0 0);
--card: oklch(0.185 0.01 200);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.185 0.01 200);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.62 0.16 162);
--primary-foreground: oklch(0.16 0.02 162);
--primary-hover: oklch(0.68 0.15 162);
--primary-soft: oklch(0.62 0.16 162 / 0.12);
--primary-soft-2: oklch(0.62 0.16 162 / 0.22);
--secondary: oklch(0.225 0.01 200);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.225 0.01 200);
--muted-foreground: oklch(0.65 0.01 200);
--accent: oklch(0.225 0.01 200);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.285 0.01 200);
--input: oklch(0.285 0.01 200);
--ring: oklch(0.62 0.16 162);
--chart-1: oklch(0.62 0.16 162);
--chart-2: oklch(0.7 0.14 195);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.165 0.01 200);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.62 0.16 162);
--sidebar-primary-foreground: oklch(0.16 0.02 162);
--sidebar-accent: oklch(0.225 0.01 200);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.285 0.01 200);
--sidebar-ring: oklch(0.62 0.16 162);
}
html[data-theme="cobalt"] {
--background: oklch(0.13 0.015 252);
--foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.015 252);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0.015 252);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.585 0.2 254);
--primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.66 0.18 254);
--primary-soft: oklch(0.585 0.2 254 / 0.12);
--primary-soft-2: oklch(0.585 0.2 254 / 0.22);
--secondary: oklch(0.22 0.015 252);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.22 0.015 252);
--muted-foreground: oklch(0.65 0.012 252);
--accent: oklch(0.22 0.015 252);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.28 0.015 252);
--input: oklch(0.28 0.015 252);
--ring: oklch(0.585 0.2 254);
--chart-1: oklch(0.585 0.2 254);
--chart-2: oklch(0.7 0.15 220);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.16 0.015 252);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.585 0.2 254);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.22 0.015 252);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.28 0.015 252);
--sidebar-ring: oklch(0.585 0.2 254);
}
html[data-theme="amber"] {
--background: oklch(0.135 0.012 60);
--foreground: oklch(0.985 0 0);
--card: oklch(0.185 0.012 60);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.185 0.012 60);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.745 0.16 65);
--primary-foreground: oklch(0.18 0.03 65);
--primary-hover: oklch(0.8 0.15 65);
--primary-soft: oklch(0.745 0.16 65 / 0.12);
--primary-soft-2: oklch(0.745 0.16 65 / 0.22);
--secondary: oklch(0.225 0.012 60);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.225 0.012 60);
--muted-foreground: oklch(0.66 0.01 60);
--accent: oklch(0.225 0.012 60);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.285 0.012 60);
--input: oklch(0.285 0.012 60);
--ring: oklch(0.745 0.16 65);
--chart-1: oklch(0.745 0.16 65);
--chart-2: oklch(0.7 0.15 35);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.165 0.012 60);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.745 0.16 65);
--sidebar-primary-foreground: oklch(0.18 0.03 65);
--sidebar-accent: oklch(0.225 0.012 60);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.285 0.012 60);
--sidebar-ring: oklch(0.745 0.16 65);
}
html[data-theme="rose"] {
--background: oklch(0.13 0.012 15);
--foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.012 15);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0.012 15);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.645 0.22 16);
--primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.71 0.2 16);
--primary-soft: oklch(0.645 0.22 16 / 0.12);
--primary-soft-2: oklch(0.645 0.22 16 / 0.22);
--secondary: oklch(0.22 0.012 15);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.22 0.012 15);
--muted-foreground: oklch(0.65 0.01 15);
--accent: oklch(0.22 0.012 15);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.28 0.012 15);
--input: oklch(0.28 0.012 15);
--ring: oklch(0.645 0.22 16);
--chart-1: oklch(0.645 0.22 16);
--chart-2: oklch(0.7 0.18 340);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.16 0.012 15);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.645 0.22 16);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.22 0.012 15);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.28 0.012 15);
--sidebar-ring: oklch(0.645 0.22 16);
}

View File

@ -220,10 +220,10 @@ export default function JoinPage() {
// ----- Loading state (peek pending OR auth not yet resolved) -----
if (peek === null || authedUserId === undefined) {
return (
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
<Card className="w-full max-w-md border-border bg-card">
<CardContent className="flex flex-col items-center gap-3 py-12">
<Loader2 className="size-6 animate-spin text-primary" />
<p className="text-sm text-slate-400">Verifying invitation…</p>
<p className="text-sm text-muted-foreground">Verifying invitation…</p>
</CardContent>
</Card>
);
@ -233,13 +233,13 @@ export default function JoinPage() {
if (!peek.ok) {
const copy = FAIL_COPY[peek.reason];
return (
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
<Card className="w-full max-w-md border-border bg-card">
<CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-red-500/10">
<MailX className="h-6 w-6 text-red-400" />
</div>
<CardTitle className="text-xl text-white">{copy.title}</CardTitle>
<CardDescription className="text-slate-400">
<CardTitle className="text-xl text-foreground">{copy.title}</CardTitle>
<CardDescription className="text-muted-foreground">
{copy.body}
</CardDescription>
</CardHeader>
@ -262,7 +262,7 @@ export default function JoinPage() {
<Link href="/signup">
<Button
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Create a new account instead
</Button>
@ -278,7 +278,7 @@ export default function JoinPage() {
<Link href="/login">
<Button
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
Sign in
</Button>
@ -296,13 +296,13 @@ export default function JoinPage() {
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
<UsersRound className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl text-white">
<CardTitle className="text-xl text-foreground">
You&apos;re invited to{' '}
<span className="text-primary">{peek.account_name}</span>
</CardTitle>
<CardDescription className="text-slate-400">
<CardDescription className="text-muted-foreground">
You&apos;ll join as{' '}
<span className="inline-flex items-center gap-1 text-white">
<span className="inline-flex items-center gap-1 text-foreground">
<ShieldCheck className="size-3.5 text-primary" />
{ROLE_LABEL[peek.role]}
</span>
@ -321,7 +321,7 @@ export default function JoinPage() {
if (authedUserId) {
return (
<>
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
<Card className="w-full max-w-md border-border bg-card">
{inviteHeader}
<CardContent className="flex flex-col gap-3">
<Button
@ -341,9 +341,9 @@ export default function JoinPage() {
</>
)}
</Button>
<p className="text-center text-xs text-slate-500">
<p className="text-center text-xs text-muted-foreground">
Accepting moves your login into{' '}
<span className="text-slate-400">{peek.account_name}</span>. Your
<span className="text-muted-foreground">{peek.account_name}</span>. Your
empty personal account from signup will be cleaned up.
</p>
</CardContent>
@ -359,30 +359,30 @@ export default function JoinPage() {
if (!open) setConflictMessage(null);
}}
>
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-md">
<DialogContent className="bg-popover border-border sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<AlertTriangle className="size-4 text-amber-400" />
Can&apos;t join {peek.account_name} with this account
</DialogTitle>
<DialogDescription className="text-slate-400">
<DialogDescription className="text-muted-foreground">
{conflictMessage}
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2 text-xs text-slate-500">
<div className="space-y-2 py-2 text-xs text-muted-foreground">
<p>
To join{' '}
<span className="text-slate-300">{peek.account_name}</span>,
<span className="text-popover-foreground">{peek.account_name}</span>,
sign out and sign up again with a different email address.
The invite link stays valid as long as it hasn&apos;t
expired.
</p>
</div>
<DialogFooter className="bg-slate-900 border-slate-700">
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setConflictMessage(null)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-popover-foreground hover:bg-muted"
>
Stay signed in
</Button>
@ -409,7 +409,7 @@ export default function JoinPage() {
// ----- Not authed: prompt to sign up or sign in -----
return (
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
<Card className="w-full max-w-md border-border bg-card">
{inviteHeader}
<CardContent className="flex flex-col gap-2">
<Link href={`/signup?invite=${encodeURIComponent(token!)}`}>
@ -420,7 +420,7 @@ export default function JoinPage() {
<Link href={`/login?invite=${encodeURIComponent(token!)}`}>
<Button
variant="outline"
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
>
I already have an account
</Button>

View File

@ -37,7 +37,7 @@ export const metadata: Metadata = {
export default function JoinLayout({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
<div className="flex min-h-screen items-center justify-center bg-background px-4">
{children}
</div>
);

View File

@ -1,10 +1,17 @@
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import Script from "next/script";
import { Toaster } from "sonner";
import "./globals.css";
import { ThemeProvider } from "@/hooks/use-theme";
import { DEFAULT_THEME, STORAGE_KEY, THEME_IDS } from "@/lib/themes";
import { ThemedToaster } from "@/components/themed-toaster";
import {
DEFAULT_MODE,
DEFAULT_THEME,
MODE_STORAGE_KEY,
MODES,
STORAGE_KEY,
THEME_IDS,
} from "@/lib/themes";
const inter = Inter({
variable: "--font-sans",
@ -33,29 +40,37 @@ export const metadata: Metadata = {
export const viewport: Viewport = {
themeColor: "#020617",
colorScheme: "dark",
colorScheme: "dark light",
};
// Inline boot script — runs before React hydrates so the user's
// chosen theme is on the <html> element before first paint. Without
// this every page load flashes the default Violet for a frame before
// the React tree mounts and applies the picked theme.
// chosen accent (data-theme) AND mode (data-mode) are on the <html>
// element before first paint. Without this every page load flashes
// the server-rendered defaults for a frame before the React tree
// mounts and applies the picked values.
//
// Kept dependency-free (no imports, no JSX) — must be a string the
// browser can run as a single <script>. Knowledge of valid theme IDs
// is sourced from the THEME_IDS constant so adding a theme doesn't
// browser can run as a single <script>. Knowledge of valid ids is
// sourced from the THEME_IDS / MODES constants so adding one doesn't
// silently break the boot path.
const THEME_BOOT_SCRIPT = `
(function(){
var d = document.documentElement;
try {
var STORAGE_KEY = ${JSON.stringify(STORAGE_KEY)};
var DEFAULT = ${JSON.stringify(DEFAULT_THEME)};
var ALLOWED = ${JSON.stringify(THEME_IDS)};
var saved = localStorage.getItem(STORAGE_KEY);
var theme = ALLOWED.indexOf(saved) !== -1 ? saved : DEFAULT;
document.documentElement.dataset.theme = theme;
var THEME_KEY = ${JSON.stringify(STORAGE_KEY)};
var THEME_DEFAULT = ${JSON.stringify(DEFAULT_THEME)};
var THEMES = ${JSON.stringify(THEME_IDS)};
var savedTheme = localStorage.getItem(THEME_KEY);
d.dataset.theme = THEMES.indexOf(savedTheme) !== -1 ? savedTheme : THEME_DEFAULT;
var MODE_KEY = ${JSON.stringify(MODE_STORAGE_KEY)};
var MODE_DEFAULT = ${JSON.stringify(DEFAULT_MODE)};
var MODES = ${JSON.stringify(MODES)};
var savedMode = localStorage.getItem(MODE_KEY);
d.dataset.mode = MODES.indexOf(savedMode) !== -1 ? savedMode : MODE_DEFAULT;
} catch (_e) {
document.documentElement.dataset.theme = ${JSON.stringify(DEFAULT_THEME)};
d.dataset.theme = ${JSON.stringify(DEFAULT_THEME)};
d.dataset.mode = ${JSON.stringify(DEFAULT_MODE)};
}
})();
`;
@ -69,13 +84,15 @@ export default function RootLayout({
<html
lang="en"
data-theme={DEFAULT_THEME}
data-mode={DEFAULT_MODE}
className={`${inter.variable} h-full antialiased`}
// The `theme-boot` script below rewrites `data-theme` on <html>
// from localStorage before React hydrates, so for any non-default
// theme the client DOM intentionally differs from the server-
// rendered `DEFAULT_THEME`. suppressHydrationWarning silences the
// expected mismatch — it only applies to this element's own
// attributes, so genuine mismatches in children still surface.
// The `theme-boot` script below rewrites `data-theme` and
// `data-mode` on <html> from localStorage before React hydrates,
// so for any non-default choice the client DOM intentionally
// differs from the server-rendered defaults. suppressHydration-
// Warning silences the expected mismatch — it only applies to
// this element's own attributes, so genuine mismatches in
// children still surface.
suppressHydrationWarning
>
<head>
@ -88,17 +105,7 @@ export default function RootLayout({
<body className="min-h-full bg-background text-foreground font-sans">
<ThemeProvider>
{children}
<Toaster
theme="dark"
position="top-right"
toastOptions={{
style: {
background: "rgb(30 41 59)",
border: "1px solid rgb(51 65 85)",
color: "white",
},
}}
/>
<ThemedToaster />
</ThemeProvider>
</body>
</html>

View File

@ -95,7 +95,7 @@ const STEP_META: Record<AutomationStepType, StepMeta> = {
assign_conversation: { label: "Assign Conversation", icon: UserCheck, border: "border-l-primary" },
update_contact_field: { label: "Update Contact Field", icon: PencilLine, border: "border-l-primary" },
create_deal: { label: "Create Deal", icon: Briefcase, border: "border-l-primary" },
wait: { label: "Wait", icon: Hourglass, border: "border-l-slate-500" },
wait: { label: "Wait", icon: Hourglass, border: "border-l-border" },
condition: { label: "Condition (If/Else)", icon: GitBranch, border: "border-l-amber-500" },
send_webhook: { label: "Send Webhook", icon: Webhook, border: "border-l-primary" },
close_conversation: { label: "Close Conversation", icon: CircleSlash, border: "border-l-primary" },
@ -251,7 +251,7 @@ function ResourcesProvider({ children }: { children: ReactNode }) {
}
const SELECT_CLASS =
"w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white focus:border-primary focus:outline-none"
"w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none"
/** Tag dropdown by name + color, storing the tag's id. Falls back to a
* raw id input when no tags exist yet. */
@ -269,7 +269,7 @@ function TagSelect({
placeholder="Tag id"
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
)
}
@ -277,7 +277,7 @@ function TagSelect({
return (
<div className="flex items-center gap-2">
<span
className="h-3 w-3 shrink-0 rounded-full border border-slate-600"
className="h-3 w-3 shrink-0 rounded-full border border-border"
style={{ backgroundColor: selected?.color ?? "transparent" }}
aria-hidden
/>
@ -358,7 +358,7 @@ function AgentSelect({
placeholder="Agent id"
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
)
}
@ -405,7 +405,7 @@ function SendTemplateFields({
onChange={(e) =>
onChange({ template_name: e.target.value, language })
}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</FieldBlock>
<FieldBlock label="Language">
@ -414,7 +414,7 @@ function SendTemplateFields({
onChange={(e) =>
onChange({ template_name: templateName, language: e.target.value })
}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</FieldBlock>
</>
@ -548,15 +548,15 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
}
return (
<div className="fixed inset-0 flex flex-col bg-slate-950">
<div className="fixed inset-0 flex flex-col bg-background">
{/* Top bar. At sub-sm widths the "Active" label is hidden and the
switch moves to the right of the save button, so the name input
gets maximum width. */}
<header className="flex flex-shrink-0 items-center gap-2 border-b border-slate-800 bg-slate-900/80 px-3 py-3 sm:gap-3 sm:px-4">
<header className="flex flex-shrink-0 items-center gap-2 border-b border-border bg-card/80 px-3 py-3 sm:gap-3 sm:px-4">
<button
type="button"
onClick={() => router.push("/automations")}
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label="Back to automations"
>
<ArrowLeft className="h-4 w-4" />
@ -565,9 +565,9 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
value={state.name}
onChange={(e) => patchTop("name", e.target.value)}
placeholder="Untitled automation"
className="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1 text-sm font-semibold text-white placeholder:text-slate-500 focus:bg-slate-800 focus:outline-none sm:text-base"
className="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1 text-sm font-semibold text-foreground placeholder:text-muted-foreground focus:bg-muted focus:outline-none sm:text-base"
/>
<div className="flex items-center gap-2 text-xs text-slate-400">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="hidden sm:inline">Active</span>
<Switch
checked={state.is_active}
@ -587,7 +587,7 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
{/* Canvas */}
<div className="relative flex-1 overflow-y-auto">
<div className="absolute inset-0 bg-[radial-gradient(circle,#1e293b_1px,transparent_1px)] [background-size:20px_20px] pointer-events-none" />
<div className="absolute inset-0 bg-[radial-gradient(circle,var(--border)_1px,transparent_1px)] [background-size:20px_20px] pointer-events-none" />
<div className="relative mx-auto flex max-w-2xl flex-col items-center gap-0 px-4 py-10">
<ResourcesProvider>
<TriggerCard
@ -633,7 +633,7 @@ function TriggerCard({
// Card width: full on mobile, fixed 320px on sm+. The canvas wrapper
// (max-w-2xl + px-4) keeps this tidy on tablet/desktop.
<div className="z-10 w-full max-w-[320px] sm:w-80">
<div className="rounded-lg border border-slate-800 border-l-4 border-l-blue-500 bg-slate-900 shadow-lg">
<div className="rounded-lg border border-border border-l-4 border-l-blue-500 bg-card shadow-lg">
<button
type="button"
onClick={() => setOpen((v) => !v)}
@ -644,24 +644,24 @@ function TriggerCard({
</div>
<div className="min-w-0 flex-1">
<div className="text-[11px] uppercase tracking-wide text-blue-300">Trigger</div>
<div className="truncate text-sm font-medium text-white">
<div className="truncate text-sm font-medium text-foreground">
{TRIGGER_OPTIONS.find((o) => o.value === type)?.label ?? type}
</div>
</div>
<ChevronDown
className={cn("h-4 w-4 text-slate-400 transition-transform", open && "rotate-180")}
className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-180")}
/>
</button>
{open && (
<div className="space-y-3 border-t border-slate-800 px-4 py-3">
<div className="space-y-3 border-t border-border px-4 py-3">
<div>
<label className="mb-1 block text-xs font-medium text-slate-400">
<label className="mb-1 block text-xs font-medium text-muted-foreground">
Trigger type
</label>
<select
value={type}
onChange={(e) => onTypeChange(e.target.value as AutomationTriggerType)}
className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white focus:border-primary focus:outline-none"
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none"
>
{TRIGGER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
@ -669,7 +669,7 @@ function TriggerCard({
</option>
))}
</select>
<p className="mt-1 text-[11px] text-slate-500">
<p className="mt-1 text-[11px] text-muted-foreground">
{TRIGGER_OPTIONS.find((o) => o.value === type)?.hint}
</p>
</div>
@ -681,7 +681,7 @@ function TriggerCard({
)}
{type === "tag_added" && (
<div>
<label className="mb-1 block text-xs font-medium text-slate-400">
<label className="mb-1 block text-xs font-medium text-muted-foreground">
Tag
</label>
<TagSelect
@ -697,7 +697,7 @@ function TriggerCard({
onChange={(e) =>
onConfigChange({ ...config, schedule: e.target.value })
}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
)}
</div>
@ -723,6 +723,18 @@ function KeywordMatchConfig({
// when the trigger type changes, so the seed stays in sync.
const [draft, setDraft] = useState(keywords.join(", "))
// Persist the default the <select> displays. The dropdown falls back to
// "contains" for display, but leaving it untouched would otherwise omit
// match_type from the saved config — and activation validation then
// rejected it (trigger.match_type). Seed once on mount; the component
// remounts when the trigger type changes, matching the keywords draft.
useEffect(() => {
if (config?.match_type == null) {
onChange({ ...config, match_type: "contains" })
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
function commit() {
const parsed = draft
.split(",")
@ -735,7 +747,7 @@ function KeywordMatchConfig({
return (
<div className="space-y-2">
<div>
<label className="mb-1 block text-xs font-medium text-slate-400">
<label className="mb-1 block text-xs font-medium text-muted-foreground">
Keywords (comma-separated)
</label>
<Input
@ -749,17 +761,17 @@ function KeywordMatchConfig({
}
}}
placeholder="e.g. pricing, demo request, talk to sales"
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-slate-400">
<label className="mb-1 block text-xs font-medium text-muted-foreground">
Match type
</label>
<select
value={config?.match_type ?? "contains"}
onChange={(e) => onChange({ ...config, match_type: e.target.value as "exact" | "contains" })}
className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white focus:outline-none"
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:outline-none"
>
<option value="contains">Contains</option>
<option value="exact">Exact</option>
@ -858,7 +870,7 @@ function StepRenderer({
<div className={cn("z-10 flex flex-col", width)}>
<div
className={cn(
"rounded-lg border border-slate-800 border-l-4 bg-slate-900 shadow-lg",
"rounded-lg border border-border border-l-4 bg-card shadow-lg",
meta.border,
)}
>
@ -867,28 +879,28 @@ function StepRenderer({
onClick={() => props.setExpandedId(expanded ? null : step.cid)}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
>
<GripVertical className="h-4 w-4 flex-shrink-0 text-slate-600" aria-hidden />
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-800 text-slate-300">
<GripVertical className="h-4 w-4 flex-shrink-0 text-muted-foreground" aria-hidden />
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Icon className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[11px] uppercase tracking-wide text-slate-400">
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
{isCondition ? "Condition" : step.step_type === "wait" ? "Wait" : "Action"}
</div>
<div className="truncate text-sm font-medium text-white">{meta.label}</div>
<div className="truncate text-[11px] text-slate-500">{previewFor(step)}</div>
<div className="truncate text-sm font-medium text-foreground">{meta.label}</div>
<div className="truncate text-[11px] text-muted-foreground">{previewFor(step)}</div>
</div>
<ChevronDown
className={cn("h-4 w-4 text-slate-400 transition-transform", expanded && "rotate-180")}
className={cn("h-4 w-4 text-muted-foreground transition-transform", expanded && "rotate-180")}
/>
</button>
{expanded && (
<div className="border-t border-slate-800 px-4 py-3">
<div className="border-t border-border px-4 py-3">
<StepEditor
step={step}
onChange={(next) => props.updateStep(path, () => next)}
/>
<div className="mt-3 flex items-center justify-between gap-2 border-t border-slate-800 pt-3">
<div className="mt-3 flex items-center justify-between gap-2 border-t border-border pt-3">
<div className="flex gap-1">
<Button
variant="ghost"
@ -927,9 +939,14 @@ function StepRenderer({
)}
</div>
{/* A condition branches into Yes/No (rendered above by
ConditionBranches), so it has no linear "continue" path — adding
the trailing connector here would produce a spurious third output. */}
{!isCondition && (
<AddButton
onPick={(t) => props.addStepAt(parentScope, index + 1, t)}
/>
)}
</>
)
}
@ -990,17 +1007,17 @@ function BranchColumn({
function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {
return (
<div className="relative flex flex-col items-center">
<div className="h-4 w-[2px] bg-slate-700" aria-hidden />
<div className="h-4 w-[2px] bg-border" aria-hidden />
<DropdownMenu>
<DropdownMenuTrigger
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-dashed border-slate-700 bg-slate-950 text-slate-400 transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary data-[popup-open]:border-primary data-[popup-open]:bg-primary/20 data-[popup-open]:text-primary"
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-dashed border-border bg-background text-muted-foreground transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary data-[popup-open]:border-primary data-[popup-open]:bg-primary/20 data-[popup-open]:text-primary"
aria-label="Add step"
>
<Plus className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="max-h-80 min-w-56 overflow-y-auto border-slate-700 bg-slate-900"
className="max-h-80 min-w-56 overflow-y-auto border-border bg-popover"
>
{ADDABLE_STEPS.map((t) => {
const Icon = STEP_META[t].icon
@ -1013,7 +1030,7 @@ function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {
})}
</DropdownMenuContent>
</DropdownMenu>
<div className="h-4 w-[2px] bg-slate-700" aria-hidden />
<div className="h-4 w-[2px] bg-border" aria-hidden />
</div>
)
}
@ -1041,7 +1058,7 @@ function StepEditor({
value={(cfg.text as string) ?? ""}
onChange={(e) => set({ text: e.target.value })}
placeholder="Hi! Thanks for reaching out…"
className="min-h-24 bg-slate-800 text-white"
className="min-h-24 bg-muted text-foreground"
/>
</FieldBlock>
)
@ -1070,7 +1087,7 @@ function StepEditor({
<select
value={(cfg.mode as string) ?? "round_robin"}
onChange={(e) => set({ mode: e.target.value })}
className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white"
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
>
<option value="round_robin">Round-robin</option>
<option value="specific">Specific agent</option>
@ -1100,7 +1117,7 @@ function StepEditor({
value={(cfg.value as string) ?? ""}
onChange={(e) => set({ value: e.target.value })}
placeholder="Text or {{ vars.x }} / {{ message.text }}"
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</FieldBlock>
</>
@ -1112,21 +1129,21 @@ function StepEditor({
<Input
value={(cfg.pipeline_id as string) ?? ""}
onChange={(e) => set({ pipeline_id: e.target.value })}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</FieldBlock>
<FieldBlock label="Stage id">
<Input
value={(cfg.stage_id as string) ?? ""}
onChange={(e) => set({ stage_id: e.target.value })}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</FieldBlock>
<FieldBlock label="Title">
<Input
value={(cfg.title as string) ?? ""}
onChange={(e) => set({ title: e.target.value })}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</FieldBlock>
<FieldBlock label="Value">
@ -1134,7 +1151,7 @@ function StepEditor({
type="number"
value={(cfg.value as number) ?? 0}
onChange={(e) => set({ value: Number(e.target.value) })}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</FieldBlock>
</>
@ -1148,14 +1165,14 @@ function StepEditor({
min={1}
value={(cfg.amount as number) ?? 1}
onChange={(e) => set({ amount: Math.max(1, Number(e.target.value)) })}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</FieldBlock>
<FieldBlock label="Unit">
<select
value={(cfg.unit as string) ?? "hours"}
onChange={(e) => set({ unit: e.target.value })}
className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white"
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
@ -1171,7 +1188,7 @@ function StepEditor({
<select
value={(cfg.subject as string) ?? "tag_presence"}
onChange={(e) => set({ subject: e.target.value })}
className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white"
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
>
<option value="tag_presence">Tag presence</option>
<option value="contact_field">Contact field</option>
@ -1192,7 +1209,7 @@ function StepEditor({
}
value={(cfg.operand as string) ?? ""}
onChange={(e) => set({ operand: e.target.value })}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</FieldBlock>
{(cfg.subject === "contact_field" || cfg.subject === "message_content") && (
@ -1200,7 +1217,7 @@ function StepEditor({
<Input
value={(cfg.value as string) ?? ""}
onChange={(e) => set({ value: e.target.value })}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</FieldBlock>
)}
@ -1213,21 +1230,21 @@ function StepEditor({
<Input
value={(cfg.url as string) ?? ""}
onChange={(e) => set({ url: e.target.value })}
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
</FieldBlock>
<FieldBlock label="Body template (JSON)">
<Textarea
value={(cfg.body_template as string) ?? ""}
onChange={(e) => set({ body_template: e.target.value })}
className="min-h-20 bg-slate-800 font-mono text-xs text-white"
className="min-h-20 bg-muted font-mono text-xs text-foreground"
/>
</FieldBlock>
</>
)
case "close_conversation":
return (
<p className="text-xs text-slate-400">
<p className="text-xs text-muted-foreground">
Sets the conversation status to &quot;closed&quot;. No configuration needed.
</p>
)
@ -1245,7 +1262,7 @@ function FieldBlock({
}) {
return (
<div className="mb-2 last:mb-0">
<label className="mb-1 block text-xs font-medium text-slate-400">{label}</label>
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
{children}
</div>
)

View File

@ -68,17 +68,17 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Choose a Template</h2>
<p className="mt-1 text-sm text-slate-400">
<h2 className="text-lg font-semibold text-foreground">Choose a Template</h2>
<p className="mt-1 text-sm text-muted-foreground">
Select an approved message template for your broadcast.
</p>
</div>
{templates.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/50">
<FileText className="mb-2 h-8 w-8 text-slate-600" />
<p className="text-sm text-slate-400">No templates available.</p>
<p className="mt-1 text-xs text-slate-500">Create a template in Settings first.</p>
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-border bg-card/50">
<FileText className="mb-2 h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">No templates available.</p>
<p className="mt-1 text-xs text-muted-foreground">Create a template in Settings first.</p>
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
@ -93,19 +93,19 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
className={`flex flex-col gap-3 rounded-xl border p-4 text-left transition-all ${
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700 hover:bg-slate-900'
: 'border-border bg-card/50 hover:border-border hover:bg-card'
}`}
>
<div className="flex items-start justify-between">
<h3 className="text-sm font-medium text-white">{template.name}</h3>
<h3 className="text-sm font-medium text-foreground">{template.name}</h3>
<span
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${catColor}`}
>
{template.category}
</span>
</div>
<p className="line-clamp-3 text-xs text-slate-400">{template.body_text}</p>
<div className="flex items-center gap-2 text-[10px] text-slate-500">
<p className="line-clamp-3 text-xs text-muted-foreground">{template.body_text}</p>
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
<span>{template.language ?? 'en_US'}</span>
{/* Status is omitted on purpose — every template
shown here is already filtered to APPROVED,
@ -117,8 +117,8 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
</div>
)}
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
<Button variant="outline" onClick={onBack} className="border-slate-700 text-slate-300">
<div className="flex items-center justify-between border-t border-border pt-4">
<Button variant="outline" onClick={onBack} className="border-border text-muted-foreground">
Back
</Button>
<Button

View File

@ -249,8 +249,8 @@ export function Step2SelectAudience({
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Select Audience</h2>
<p className="mt-1 text-sm text-slate-400">
<h2 className="text-lg font-semibold text-foreground">Select Audience</h2>
<p className="mt-1 text-sm text-muted-foreground">
Choose who will receive this broadcast.
</p>
</div>
@ -280,21 +280,21 @@ export function Step2SelectAudience({
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
isSelected
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700'
: 'border-border bg-card/50 hover:border-border'
}`}
>
<div
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
isSelected
? 'bg-primary/10 text-primary'
: 'bg-slate-800 text-slate-400'
: 'bg-muted text-muted-foreground'
}`}
>
<Icon className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-white">{option.label}</p>
<p className="mt-0.5 text-xs text-slate-400">
<p className="text-sm font-medium text-foreground">{option.label}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{option.description}
</p>
</div>
@ -304,12 +304,12 @@ export function Step2SelectAudience({
</div>
{audience.type === 'tags' && (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<p className="mb-3 text-sm font-medium text-white">Select Tags</p>
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="mb-3 text-sm font-medium text-foreground">Select Tags</p>
{loadingTags ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : tags.length === 0 ? (
<p className="text-xs text-slate-400">
<p className="text-xs text-muted-foreground">
No tags found. Create tags in Settings.
</p>
) : (
@ -323,7 +323,7 @@ export function Step2SelectAudience({
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
isSelected
? 'border-primary/30 bg-primary/10 text-primary'
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600'
: 'border-border bg-muted text-muted-foreground hover:border-border'
}`}
>
<span
@ -340,12 +340,12 @@ export function Step2SelectAudience({
)}
{audience.type === 'custom_field' && (
<div className="space-y-3 rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<p className="text-sm font-medium text-white">Custom Field Filter</p>
<div className="space-y-3 rounded-xl border border-border bg-card/50 p-4">
<p className="text-sm font-medium text-foreground">Custom Field Filter</p>
{loadingFields ? (
<Loader2 className="h-5 w-5 animate-spin text-primary" />
) : customFields.length === 0 ? (
<p className="text-xs text-slate-400">
<p className="text-xs text-muted-foreground">
No custom fields defined. Create one in Settings → Custom Fields.
</p>
) : (
@ -353,7 +353,7 @@ export function Step2SelectAudience({
<select
value={audience.customField?.fieldId ?? ''}
onChange={(e) => updateCustomField({ fieldId: e.target.value })}
className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary focus:ring-1 focus:ring-primary"
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
>
<option value="">Select field…</option>
{customFields.map((f) => (
@ -369,7 +369,7 @@ export function Step2SelectAudience({
operator: e.target.value as CustomFieldOperator,
})
}
className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary focus:ring-1 focus:ring-primary"
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
>
{OPERATOR_OPTIONS.map((op) => (
<option key={op.value} value={op.value}>
@ -382,7 +382,7 @@ export function Step2SelectAudience({
value={audience.customField?.value ?? ''}
onChange={(e) => updateCustomField({ value: e.target.value })}
placeholder="Value"
className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none placeholder:text-slate-500 focus:border-primary focus:ring-1 focus:ring-primary"
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
/>
</div>
)}
@ -390,16 +390,16 @@ export function Step2SelectAudience({
)}
{/* Exclude list — applies regardless of audience type */}
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2">
<X className="h-4 w-4 text-red-400" />
<p className="text-sm font-medium text-white">
<p className="text-sm font-medium text-foreground">
Exclude contacts with these tags
</p>
<span className="text-xs text-slate-500">(optional)</span>
<span className="text-xs text-muted-foreground">(optional)</span>
</div>
{tags.length === 0 ? (
<p className="text-xs text-slate-500">No tags available.</p>
<p className="text-xs text-muted-foreground">No tags available.</p>
) : (
<div className="flex flex-wrap gap-2">
{tags.map((tag) => {
@ -411,7 +411,7 @@ export function Step2SelectAudience({
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
isExcluded
? 'border-red-500/30 bg-red-500/10 text-red-300'
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600'
: 'border-border bg-muted text-muted-foreground hover:border-border'
}`}
>
<span
@ -427,33 +427,33 @@ export function Step2SelectAudience({
</div>
{/* Audience Summary */}
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<p className="mb-2 text-sm font-medium text-white">Audience Summary</p>
<div className="rounded-xl border border-border bg-card/50 p-4">
<p className="mb-2 text-sm font-medium text-foreground">Audience Summary</p>
{loadingCount ? (
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<span className="text-xs text-slate-400">Calculating…</span>
<span className="text-xs text-muted-foreground">Calculating…</span>
</div>
) : estimatedCount !== null ? (
<div className="flex items-center gap-2">
<Users className="h-4 w-4 text-primary" />
<span className="text-sm text-white">
<span className="text-sm text-foreground">
{estimatedCount.toLocaleString()}
</span>
<span className="text-xs text-slate-400">estimated recipients</span>
<span className="text-xs text-muted-foreground">estimated recipients</span>
</div>
) : (
<p className="text-xs text-slate-500">
<p className="text-xs text-muted-foreground">
Select an audience type to see the estimate.
</p>
)}
</div>
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
<div className="flex items-center justify-between border-t border-border pt-4">
<Button
variant="outline"
onClick={onBack}
className="border-slate-700 text-slate-300"
className="border-border text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back

View File

@ -12,7 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ArrowLeft, ArrowRight, Eye, Loader2 } from 'lucide-react';
import { ArrowLeft, ArrowRight, Eye, ImageIcon, Loader2 } from 'lucide-react';
type VariableType = 'static' | 'field' | 'custom_field';
@ -25,10 +25,29 @@ interface Step3Props {
template: MessageTemplate;
variables: Record<string, VariableMapping>;
onUpdate: (variables: Record<string, VariableMapping>) => void;
/** Media URL for an IMAGE/VIDEO/DOCUMENT header, when the template has one. */
headerMediaUrl: string;
onHeaderMediaUrlChange: (url: string) => void;
onNext: () => void;
onBack: () => void;
}
const MEDIA_HEADER_TYPES = ['image', 'video', 'document'] as const;
type MediaHeaderType = (typeof MEDIA_HEADER_TYPES)[number];
function isMediaHeaderType(value: unknown): value is MediaHeaderType {
return MEDIA_HEADER_TYPES.includes(value as MediaHeaderType);
}
function isValidHttpUrl(value: string): boolean {
try {
const u = new URL(value);
return u.protocol === 'http:' || u.protocol === 'https:';
} catch {
return false;
}
}
const contactFields = [
{ value: 'name', label: 'Contact Name' },
{ value: 'phone', label: 'Phone Number' },
@ -52,6 +71,8 @@ export function Step3Personalize({
template,
variables,
onUpdate,
headerMediaUrl,
onHeaderMediaUrlChange,
onNext,
onBack,
}: Step3Props) {
@ -112,6 +133,33 @@ export function Step3Personalize({
return [...new Set(matches)].sort();
}, [template.body_text]);
// Templates with an IMAGE/VIDEO/DOCUMENT header need a media URL at
// send time — Meta requires the media component on every delivery and
// rejects the broadcast without it. The field is hidden for text-only
// headers.
const mediaHeaderType = isMediaHeaderType(template.header_type)
? template.header_type
: null;
// Seed the field with the template's stored sample URL the first time
// we land on a media-header template, so the common "reuse the
// approved media" case needs no typing. Only seeds when empty to avoid
// clobbering a URL the user already edited.
useEffect(() => {
if (mediaHeaderType && !headerMediaUrl && template.header_media_url) {
onHeaderMediaUrlChange(template.header_media_url);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mediaHeaderType, template.header_media_url]);
const headerMediaError = useMemo<'missing' | 'invalid' | null>(() => {
if (!mediaHeaderType) return null;
const value = headerMediaUrl.trim();
if (!value) return 'missing';
if (!isValidHttpUrl(value)) return 'invalid';
return null;
}, [mediaHeaderType, headerMediaUrl]);
/**
* A placeholder is "unmapped" if the user hasn't picked either a
* static value or a field/custom-field source. Blocks Next until
@ -187,20 +235,69 @@ export function Step3Personalize({
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Personalize Message</h2>
<p className="mt-1 text-sm text-slate-400">
<h2 className="text-lg font-semibold text-foreground">Personalize Message</h2>
<p className="mt-1 text-sm text-muted-foreground">
Map template variables to contact fields, custom fields, or static
values.
</p>
</div>
{placeholders.length === 0 ? (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-6 text-center">
<p className="text-sm text-slate-400">
{mediaHeaderType && (
<div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-primary" />
<p className="text-sm font-medium text-foreground">Header media</p>
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium uppercase text-primary">
{mediaHeaderType}
</span>
</div>
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Media URL
</label>
<Input
type="url"
value={headerMediaUrl}
onChange={(e) => onHeaderMediaUrlChange(e.target.value)}
placeholder={`https://example.com/header.${
mediaHeaderType === 'image'
? 'jpg'
: mediaHeaderType === 'video'
? 'mp4'
: 'pdf'
}`}
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
<p className="mt-1.5 text-xs text-muted-foreground">
Public URL of the {mediaHeaderType} sent as the message header.
Used for every recipient in this broadcast.
</p>
{mediaHeaderType === 'image' &&
headerMediaError === null &&
headerMediaUrl.trim() && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={headerMediaUrl.trim()}
alt="Header preview"
className="mt-3 max-h-40 rounded-lg border border-border object-contain"
/>
)}
{headerMediaError && (
<p className="mt-1.5 text-xs text-amber-300">
{headerMediaError === 'missing'
? 'A media URL is required to send this template.'
: 'Enter a valid http(s) URL.'}
</p>
)}
</div>
)}
{placeholders.length === 0 && !mediaHeaderType ? (
<div className="rounded-xl border border-border bg-card/50 p-6 text-center">
<p className="text-sm text-muted-foreground">
This template has no variables to personalize.
</p>
</div>
) : (
) : placeholders.length === 0 ? null : (
<div className="space-y-4">
{placeholders.map((placeholder) => {
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
@ -209,7 +306,7 @@ export function Step3Personalize({
return (
<div
key={placeholder}
className="rounded-xl border border-slate-800 bg-slate-900/50 p-4"
className="rounded-xl border border-border bg-card/50 p-4"
>
<div className="mb-3 flex items-center gap-2">
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-mono font-medium text-primary">
@ -219,7 +316,7 @@ export function Step3Personalize({
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-xs font-medium text-slate-400">
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
Mapping Type
</label>
<Select
@ -231,10 +328,10 @@ export function Step3Personalize({
})
}
>
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
<SelectTrigger className="w-full border-border bg-muted text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent className="border-slate-700 bg-slate-800">
<SelectContent className="border-border bg-popover">
<SelectItem value="static">Static Value</SelectItem>
<SelectItem value="field">Contact Field</SelectItem>
<SelectItem value="custom_field">
@ -245,7 +342,7 @@ export function Step3Personalize({
</div>
<div>
<label className="mb-1.5 block text-xs font-medium text-slate-400">
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
{mapping.type === 'static' ? 'Value' : 'Field'}
</label>
{mapping.type === 'static' ? (
@ -255,7 +352,7 @@ export function Step3Personalize({
updateVariable(key, { value: e.target.value })
}
placeholder="Enter value..."
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
) : mapping.type === 'field' ? (
<Select
@ -264,10 +361,10 @@ export function Step3Personalize({
updateVariable(key, { value: val || '' })
}
>
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
<SelectTrigger className="w-full border-border bg-muted text-foreground">
<SelectValue placeholder="Select field..." />
</SelectTrigger>
<SelectContent className="border-slate-700 bg-slate-800">
<SelectContent className="border-border bg-popover">
{contactFields.map((field) => (
<SelectItem key={field.value} value={field.value}>
{field.label}
@ -282,7 +379,7 @@ export function Step3Personalize({
updateVariable(key, { value: val || '' })
}
>
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
<SelectTrigger className="w-full border-border bg-muted text-foreground">
<SelectValue
placeholder={
loadingFields
@ -293,7 +390,7 @@ export function Step3Personalize({
}
/>
</SelectTrigger>
<SelectContent className="border-slate-700 bg-slate-800">
<SelectContent className="border-border bg-popover">
{customFields.map((f) => (
<SelectItem key={f.id} value={f.id}>
{f.field_name}
@ -312,11 +409,11 @@ export function Step3Personalize({
{/* Live Preview — rendered as a WhatsApp-style bubble so the user
sees approximately what the recipient will see. */}
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="rounded-xl border border-border bg-card/50 p-4">
<div className="mb-3 flex items-center gap-2">
<Eye className="h-4 w-4 text-primary" />
<p className="text-sm font-medium text-white">Live Preview</p>
<span className="text-xs text-slate-500">({previewLabel})</span>
<p className="text-sm font-medium text-foreground">Live Preview</p>
<span className="text-xs text-muted-foreground">({previewLabel})</span>
{loadingPreview && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
)}
@ -340,18 +437,18 @@ export function Step3Personalize({
</div>
)}
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
<div className="flex items-center justify-between border-t border-border pt-4">
<Button
variant="outline"
onClick={onBack}
className="border-slate-700 text-slate-300"
className="border-border text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
</Button>
<Button
onClick={onNext}
disabled={unmappedKeys.length > 0}
disabled={unmappedKeys.length > 0 || headerMediaError !== null}
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
Next

View File

@ -93,51 +93,51 @@ export function Step4ScheduleSend({
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold text-white">Review & Send</h2>
<p className="mt-1 text-sm text-slate-400">
<h2 className="text-lg font-semibold text-foreground">Review & Send</h2>
<p className="mt-1 text-sm text-muted-foreground">
Name your broadcast, review the details, and send.
</p>
</div>
{/* Broadcast Name */}
<div>
<label className="mb-1.5 block text-sm font-medium text-white">Broadcast Name</label>
<label className="mb-1.5 block text-sm font-medium text-foreground">Broadcast Name</label>
<Input
value={name}
onChange={(e) => onNameChange(e.target.value)}
placeholder="e.g. Summer Sale Announcement"
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
</div>
{/* Summary Card */}
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4 space-y-3">
<p className="text-sm font-medium text-white">Summary</p>
<div className="rounded-xl border border-border bg-card/50 p-4 space-y-3">
<p className="text-sm font-medium text-foreground">Summary</p>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-slate-400">Template</p>
<p className="text-white">{template.name}</p>
<p className="text-xs text-muted-foreground">Template</p>
<p className="text-foreground">{template.name}</p>
</div>
<div>
<p className="text-xs text-slate-400">Audience</p>
<p className="text-white">{audienceLabel}</p>
<p className="text-xs text-muted-foreground">Audience</p>
<p className="text-foreground">{audienceLabel}</p>
</div>
<div>
<p className="text-xs text-slate-400">Estimated Reach</p>
<p className="text-xs text-muted-foreground">Estimated Reach</p>
<div className="flex items-center gap-1.5">
{loadingReach ? (
<Loader2 className="h-3 w-3 animate-spin text-primary" />
) : (
<>
<Users className="h-3.5 w-3.5 text-primary" />
<p className="font-medium text-white">{estimatedReach.toLocaleString()}</p>
<p className="font-medium text-foreground">{estimatedReach.toLocaleString()}</p>
</>
)}
</div>
</div>
<div>
<p className="text-xs text-slate-400">Language</p>
<p className="text-white">{template.language ?? 'en_US'}</p>
<p className="text-xs text-muted-foreground">Language</p>
<p className="text-foreground">{template.language ?? 'en_US'}</p>
</div>
</div>
</div>
@ -148,11 +148,11 @@ export function Step4ScheduleSend({
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-primary" />
<p className="text-sm font-medium text-white">Sending broadcast...</p>
<p className="text-sm font-medium text-foreground">Sending broadcast...</p>
</div>
<span className="text-xs font-medium text-primary">{progress}%</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-800">
<div className="h-1.5 w-full rounded-full bg-muted">
<div
className="h-1.5 rounded-full bg-primary transition-all duration-300"
style={{ width: `${progress}%` }}
@ -161,12 +161,12 @@ export function Step4ScheduleSend({
</div>
)}
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-800 pt-4">
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
<Button
variant="outline"
onClick={onBack}
disabled={isProcessing}
className="border-slate-700 text-slate-300"
className="border-border text-muted-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back
@ -178,7 +178,7 @@ export function Step4ScheduleSend({
variant="outline"
onClick={onSaveDraft}
disabled={!name.trim() || isProcessing}
className="border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50"
className="border-border text-muted-foreground hover:bg-muted disabled:opacity-50"
>
<Save className="h-4 w-4" />
Save as Draft
@ -197,14 +197,14 @@ export function Step4ScheduleSend({
<Send className="h-4 w-4" />
Send Broadcast
</DialogTrigger>
<DialogContent className="border-slate-700 bg-slate-900 sm:max-w-md">
<DialogContent className="border-border bg-popover sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-white">Confirm Broadcast</DialogTitle>
<DialogDescription className="text-slate-400">
<DialogTitle className="text-popover-foreground">Confirm Broadcast</DialogTitle>
<DialogDescription className="text-muted-foreground">
You are about to send this broadcast to{' '}
<span className="font-medium text-white">{estimatedReach.toLocaleString()}</span>{' '}
<span className="font-medium text-popover-foreground">{estimatedReach.toLocaleString()}</span>{' '}
contacts using the{' '}
<span className="font-medium text-white">{template.name}</span> template.
<span className="font-medium text-popover-foreground">{template.name}</span> template.
This action cannot be undone.
</DialogDescription>
</DialogHeader>
@ -212,7 +212,7 @@ export function Step4ScheduleSend({
<Button
variant="outline"
onClick={() => setShowConfirm(false)}
className="border-slate-700 text-slate-300"
className="border-border text-muted-foreground"
>
Cancel
</Button>

View File

@ -5,7 +5,11 @@ import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { formatCurrency } from '@/lib/currency';
import { toast } from 'sonner';
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types';
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal, MessageTemplate } from '@/types';
import {
TemplatePicker,
type TemplateSendValues,
} from '@/components/inbox/template-picker';
import {
Sheet,
SheetContent,
@ -33,6 +37,7 @@ import {
Save,
X,
DollarSign,
LayoutTemplate,
} from 'lucide-react';
interface ContactDetailViewProps {
@ -55,6 +60,12 @@ export function ContactDetailView({
const [loading, setLoading] = useState(false);
const [copiedPhone, setCopiedPhone] = useState(false);
// Send template — lets the business initiate (or re-open) a conversation
// with this contact by sending an approved template. The send route
// find-or-creates the conversation, so no inbound message is required.
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
const [sendingTemplate, setSendingTemplate] = useState(false);
// Details tab
const [editName, setEditName] = useState('');
const [editPhone, setEditPhone] = useState('');
@ -317,6 +328,48 @@ export function ContactDetailView({
setSavingCustom(false);
}
async function handleSendTemplate(
template: MessageTemplate,
values: TemplateSendValues,
) {
if (!contactId) return;
setSendingTemplate(true);
try {
const res = await fetch('/api/whatsapp/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// No conversation_id — the route find-or-creates one for this
// contact, mirroring the inbox template-send payload otherwise.
contact_id: contactId,
message_type: 'template',
template_name: template.name,
template_language: template.language,
template_message_params: {
body: values.body,
headerText: values.headerText,
buttonParams: values.buttonParams,
},
template_params: values.body,
}),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
const reason = payload?.error || `HTTP ${res.status}`;
toast.error(`Failed to send template: ${reason}`);
return;
}
toast.success(`Template "${template.name}" sent`);
} catch (err) {
const reason = err instanceof Error ? err.message : 'network error';
toast.error(`Failed to send template: ${reason}`);
} finally {
setSendingTemplate(false);
}
}
function getInitials(name?: string | null) {
if (!name) return '?';
return name
@ -328,10 +381,11 @@ export function ContactDetailView({
}
return (
<>
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg w-full p-0"
className="bg-popover border-border text-popover-foreground sm:max-w-lg w-full p-0"
>
{loading || !contact ? (
<div className="flex items-center justify-center h-full">
@ -340,21 +394,21 @@ export function ContactDetailView({
) : (
<div className="flex flex-col h-full">
{/* Header */}
<SheetHeader className="p-4 border-b border-slate-700/50">
<SheetHeader className="p-4 border-b border-border/50">
<div className="flex items-center gap-3">
<Avatar className="size-12 bg-slate-800 border border-slate-700">
<Avatar className="size-12 bg-muted border border-border">
<AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
{getInitials(contact.name)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<SheetTitle className="text-white truncate">
<SheetTitle className="text-popover-foreground truncate">
{contact.name || 'Unknown'}
</SheetTitle>
<SheetDescription className="text-slate-400 text-xs mt-0.5">
<SheetDescription className="text-muted-foreground text-xs mt-0.5">
Contact details
</SheetDescription>
<div className="flex flex-wrap items-center gap-3 mt-1.5 text-xs text-slate-400">
<div className="flex flex-wrap items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<button
onClick={copyPhone}
className="flex items-center gap-1 hover:text-primary transition-colors cursor-pointer"
@ -382,38 +436,53 @@ export function ContactDetailView({
</div>
</div>
</div>
<div className="mt-3">
<Button
size="sm"
onClick={() => setTemplatePickerOpen(true)}
disabled={sendingTemplate}
className="bg-primary text-primary-foreground hover:bg-primary/90"
>
{sendingTemplate ? (
<Loader2 className="size-4 animate-spin" />
) : (
<LayoutTemplate className="size-4" />
)}
Send template
</Button>
</div>
</SheetHeader>
{/* Tabs */}
<Tabs defaultValue="details" className="flex-1 flex flex-col min-h-0">
<TabsList className="bg-slate-800/50 border-b border-slate-700 mx-4 mt-3">
<TabsList className="bg-muted/50 border-b border-border mx-4 mt-3">
<TabsTrigger
value="details"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Details
</TabsTrigger>
<TabsTrigger
value="tags"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Tags
</TabsTrigger>
<TabsTrigger
value="notes"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Notes
</TabsTrigger>
<TabsTrigger
value="custom"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Custom Fields
</TabsTrigger>
<TabsTrigger
value="deals"
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
>
Deals
</TabsTrigger>
@ -423,37 +492,37 @@ export function ContactDetailView({
<TabsContent value="details" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-3">
<div className="space-y-1.5">
<Label className="text-slate-400 text-xs">Name</Label>
<Label className="text-muted-foreground text-xs">Name</Label>
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-400 text-xs">
<Label className="text-muted-foreground text-xs">
Phone <span className="text-red-400">*</span>
</Label>
<Input
value={editPhone}
onChange={(e) => setEditPhone(e.target.value)}
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-400 text-xs">Email</Label>
<Label className="text-muted-foreground text-xs">Email</Label>
<Input
value={editEmail}
onChange={(e) => setEditEmail(e.target.value)}
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-slate-400 text-xs">Company</Label>
<Label className="text-muted-foreground text-xs">Company</Label>
<Input
value={editCompany}
onChange={(e) => setEditCompany(e.target.value)}
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
className="bg-muted border-border text-foreground h-8 text-sm"
/>
</div>
<Button
@ -475,11 +544,11 @@ export function ContactDetailView({
{/* Tags Tab */}
<TabsContent value="tags" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-3">
<p className="text-xs text-slate-400">
<p className="text-xs text-muted-foreground">
Click a tag to add or remove it from this contact.
</p>
{allTags.length === 0 ? (
<p className="text-sm text-slate-500">
<p className="text-sm text-muted-foreground">
No tags available. Create tags in Settings.
</p>
) : (
@ -493,7 +562,7 @@ export function ContactDetailView({
disabled={savingTags}
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium transition-all cursor-pointer ${
selected
? 'ring-2 ring-primary ring-offset-1 ring-offset-slate-900'
? 'ring-2 ring-primary ring-offset-1 ring-offset-border'
: 'opacity-50 hover:opacity-80'
}`}
style={{
@ -518,7 +587,7 @@ export function ContactDetailView({
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
placeholder="Write a note..."
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 min-h-[60px] text-sm resize-none"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[60px] text-sm resize-none"
/>
<Button
onClick={addNote}
@ -538,30 +607,30 @@ export function ContactDetailView({
<div className="flex-1 overflow-y-auto space-y-2">
{loadingNotes ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-slate-500" />
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : notes.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-8">
<p className="text-sm text-muted-foreground text-center py-8">
No notes yet.
</p>
) : (
notes.map((note) => (
<div
key={note.id}
className="rounded-lg bg-slate-800/50 border border-slate-700/50 p-3 group"
className="rounded-lg bg-muted/50 border border-border/50 p-3 group"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm text-slate-300 whitespace-pre-wrap flex-1">
<p className="text-sm text-muted-foreground whitespace-pre-wrap flex-1">
{note.note_text}
</p>
<button
onClick={() => deleteNote(note.id)}
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all cursor-pointer shrink-0"
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-red-400 transition-all cursor-pointer shrink-0"
>
<Trash2 className="size-3.5" />
</button>
</div>
<p className="text-xs text-slate-500 mt-1.5">
<p className="text-xs text-muted-foreground mt-1.5">
{new Date(note.created_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
@ -580,17 +649,17 @@ export function ContactDetailView({
<TabsContent value="custom" className="flex-1 overflow-y-auto px-4 py-3">
{loadingCustom ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="size-5 animate-spin text-slate-500" />
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : customFields.length === 0 ? (
<p className="text-sm text-slate-500 text-center py-8">
<p className="text-sm text-muted-foreground text-center py-8">
No custom fields defined. Create them in Settings.
</p>
) : (
<div className="space-y-3">
{customFields.map((field) => (
<div key={field.id} className="space-y-1.5">
<Label className="text-slate-400 text-xs capitalize">
<Label className="text-muted-foreground text-xs capitalize">
{field.field_name}
</Label>
<Input
@ -602,7 +671,7 @@ export function ContactDetailView({
}))
}
placeholder={`Enter ${field.field_name}...`}
className="bg-slate-800 border-slate-700 text-white h-8 text-sm placeholder:text-slate-500"
className="bg-muted border-border text-foreground h-8 text-sm placeholder:text-muted-foreground"
/>
</div>
))}
@ -630,16 +699,16 @@ export function ContactDetailView({
<Loader2 className="size-5 animate-spin text-primary" />
</div>
) : deals.length === 0 ? (
<p className="text-xs text-slate-500">No deals yet</p>
<p className="text-xs text-muted-foreground">No deals yet</p>
) : (
<div className="space-y-2">
{deals.map((deal) => (
<div
key={deal.id}
className="rounded-lg border border-slate-700 bg-slate-800/50 p-3"
className="rounded-lg border border-border bg-muted/50 p-3"
>
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-medium text-white">
<p className="text-sm font-medium text-foreground">
{deal.title}
</p>
{deal.stage && (
@ -654,7 +723,7 @@ export function ContactDetailView({
</span>
)}
</div>
<div className="mt-1.5 flex items-center justify-between text-xs text-slate-400">
<div className="mt-1.5 flex items-center justify-between text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<DollarSign className="size-3" />
{formatCurrency(
@ -684,5 +753,11 @@ export function ContactDetailView({
)}
</SheetContent>
</Sheet>
<TemplatePicker
open={templatePickerOpen}
onOpenChange={setTemplatePickerOpen}
onSelect={handleSendTemplate}
/>
</>
);
}

View File

@ -223,12 +223,12 @@ export function ContactForm({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-md">
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-white">
<DialogTitle className="text-popover-foreground">
{isEdit ? 'Edit Contact' : 'Add Contact'}
</DialogTitle>
<DialogDescription className="text-slate-400">
<DialogDescription className="text-muted-foreground">
{isEdit
? 'Update the contact details below.'
: 'Fill in the details to create a new contact.'}
@ -237,7 +237,7 @@ export function ContactForm({
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="cf-name" className="text-slate-300">
<Label htmlFor="cf-name" className="text-muted-foreground">
Name
</Label>
<Input
@ -245,12 +245,12 @@ export function ContactForm({
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cf-phone" className="text-slate-300">
<Label htmlFor="cf-phone" className="text-muted-foreground">
Phone <span className="text-red-400">*</span>
</Label>
<Input
@ -262,7 +262,7 @@ export function ContactForm({
}}
onBlur={checkDuplicate}
placeholder="+1 234 567 8900"
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
{dupMatch ? (
<div
@ -291,14 +291,14 @@ export function ContactForm({
</div>
</div>
) : (
<p className="text-xs text-slate-500">
<p className="text-xs text-muted-foreground">
Include country code, e.g. +1 for US
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="cf-email" className="text-slate-300">
<Label htmlFor="cf-email" className="text-muted-foreground">
Email
</Label>
<Input
@ -307,12 +307,12 @@ export function ContactForm({
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="john@example.com"
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cf-company" className="text-slate-300">
<Label htmlFor="cf-company" className="text-muted-foreground">
Company
</Label>
<Input
@ -320,19 +320,19 @@ export function ContactForm({
value={company}
onChange={(e) => setCompany(e.target.value)}
placeholder="Acme Inc."
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
</div>
<div className="space-y-2">
<Label className="text-slate-300">Tags</Label>
<Label className="text-muted-foreground">Tags</Label>
{loadingTags ? (
<div className="flex items-center gap-2 text-slate-500 text-sm">
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Loader2 className="size-3 animate-spin" />
Loading tags...
</div>
) : tags.length === 0 ? (
<p className="text-xs text-slate-500">
<p className="text-xs text-muted-foreground">
No tags available. Create tags in Settings.
</p>
) : (
@ -346,7 +346,7 @@ export function ContactForm({
onClick={() => toggleTag(tag.id)}
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors cursor-pointer ${
selected
? 'ring-2 ring-primary ring-offset-1 ring-offset-slate-900'
? 'ring-2 ring-primary ring-offset-1 ring-offset-border'
: 'opacity-60 hover:opacity-100'
}`}
style={{
@ -363,12 +363,12 @@ export function ContactForm({
)}
</div>
<DialogFooter className="bg-slate-900 border-slate-700">
<DialogFooter className="bg-popover border-border">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>

View File

@ -33,10 +33,10 @@ export function CustomFieldsManager({
}: CustomFieldsManagerProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="border-slate-700 bg-slate-900 text-slate-200 sm:max-w-md">
<DialogContent className="border-border bg-popover text-popover-foreground sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-white">Custom fields</DialogTitle>
<DialogDescription className="text-slate-400">
<DialogTitle className="text-popover-foreground">Custom fields</DialogTitle>
<DialogDescription className="text-muted-foreground">
Define extra contact fields (e.g. ZIP code, lead source). They
appear on every contact and in the “Update Contact Field” automation
action.
@ -185,7 +185,7 @@ export function CustomFieldsPanel() {
}
}}
placeholder="New field name…"
className="bg-slate-800 text-white"
className="bg-muted text-foreground"
/>
<Button
onClick={handleCreate}
@ -202,18 +202,18 @@ export function CustomFieldsPanel() {
</div>
{/* List */}
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-800">
<div className="max-h-72 overflow-y-auto rounded-md border border-border">
{loading ? (
<div className="flex items-center justify-center gap-2 py-8 text-sm text-slate-500">
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Loading…
</div>
) : fields.length === 0 ? (
<p className="py-8 text-center text-sm text-slate-500">
<p className="py-8 text-center text-sm text-muted-foreground">
No custom fields yet.
</p>
) : (
<ul className="divide-y divide-slate-800">
<ul className="divide-y divide-border">
{fields.map((field) => (
<FieldRow
key={field.id}
@ -265,7 +265,7 @@ function FieldRow({
if (e.key === 'Enter') e.currentTarget.blur();
}}
aria-label={`Rename ${field.field_name}`}
className="focus:border-primary h-8 border-transparent bg-transparent text-white hover:border-slate-700"
className="focus:border-primary h-8 border-transparent bg-transparent text-foreground hover:border-border"
/>
<Button
variant="ghost"
@ -273,7 +273,7 @@ function FieldRow({
disabled={busy}
onClick={() => onDelete(field)}
title="Delete field"
className="shrink-0 text-slate-400 hover:text-red-400"
className="shrink-0 text-muted-foreground hover:text-red-400"
>
{busy ? (
<Loader2 className="size-4 animate-spin" />

View File

@ -1,6 +1,6 @@
'use client';
import { useState, useRef } from 'react';
import { useMemo, useRef, useState } from 'react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import {
@ -8,6 +8,16 @@ import {
isUniqueViolation,
normalizeKey,
} from '@/lib/contacts/dedupe';
import {
parseContactCsv,
type ParsedContactRow,
} from '@/lib/contacts/parse-contact-csv';
import {
assignImportedContactTags,
resolveImportTagIds,
type ContactTagAssignment,
} from '@/lib/contacts/resolve-import-tags';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';
import {
Dialog,
@ -18,7 +28,89 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Upload, FileText, Loader2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
import {
Upload,
FileText,
Loader2,
CheckCircle,
XCircle,
AlertTriangle,
Tag,
} from 'lucide-react';
const DEFAULT_TAG_COLOR = '#3b82f6';
const PREVIEW_LIMIT = 5;
function truncateFilename(name: string, max = 48): string {
if (name.length <= max) return name;
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
const base = name.slice(0, name.length - ext.length);
const keep = max - ext.length - 1;
return `${base.slice(0, Math.max(keep, 12))}…${ext}`;
}
function PreviewCell({
value,
mono,
maxWidth = 'max-w-[9rem]',
}: {
value: string;
mono?: boolean;
maxWidth?: string;
}) {
return (
<span
className={cn(
'block truncate',
maxWidth,
mono && 'font-mono text-[11px]'
)}
title={value}
>
{value}
</span>
);
}
function ImportPreviewTags({
tagNames,
tagColorByKey,
}: {
tagNames: string[];
tagColorByKey: Map<string, string>;
}) {
if (tagNames.length === 0) {
return <span className="text-muted-foreground">—</span>;
}
return (
<div className="flex min-w-[4.5rem] flex-wrap gap-1">
{tagNames.map((name) => {
const color =
tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR;
const isKnown = tagColorByKey.has(name.trim().toLowerCase());
return (
<span
key={name}
className="inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-[10px] leading-none font-medium"
style={{
backgroundColor: `${color}18`,
color,
border: `1px solid ${color}${isKnown ? '55' : '30'}`,
}}
title={isKnown ? name : `${name} (will be created on import)`}
>
<span
className="size-1.5 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="truncate">{name}</span>
</span>
);
})}
</div>
);
}
interface ImportModalProps {
open: boolean;
@ -26,87 +118,43 @@ interface ImportModalProps {
onImported: () => void;
}
interface ParsedRow {
phone: string;
name?: string;
email?: string;
company?: string;
}
function parseCSV(text: string): ParsedRow[] {
const lines = text.trim().split(/\r?\n/);
if (lines.length < 2) return [];
const headerLine = lines[0];
const headers = headerLine.split(',').map((h) => h.trim().toLowerCase().replace(/["']/g, ''));
const phoneIdx = headers.indexOf('phone');
if (phoneIdx === -1) return [];
const nameIdx = headers.indexOf('name');
const emailIdx = headers.indexOf('email');
const companyIdx = headers.indexOf('company');
const rows: ParsedRow[] = [];
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Simple CSV parse (handles quoted fields)
const values: string[] = [];
let current = '';
let inQuotes = false;
for (const char of line) {
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === ',' && !inQuotes) {
values.push(current.trim());
current = '';
} else {
current += char;
}
}
values.push(current.trim());
const phone = values[phoneIdx]?.replace(/["']/g, '').trim();
if (!phone) continue;
rows.push({
phone,
name: nameIdx >= 0 ? values[nameIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
email: emailIdx >= 0 ? values[emailIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
company:
companyIdx >= 0 ? values[companyIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
});
}
return rows;
}
export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) {
export function ImportModal({
open,
onOpenChange,
onImported,
}: ImportModalProps) {
const supabase = createClient();
const { accountId } = useAuth();
const { accountId, canEditSettings } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(null);
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
const [parsedRows, setParsedRows] = useState<ParsedContactRow[]>([]);
const [hasTagsColumn, setHasTagsColumn] = useState(false);
const [hasCompanyColumn, setHasCompanyColumn] = useState(false);
const [tagColorByKey, setTagColorByKey] = useState<Map<string, string>>(
new Map()
);
const [importing, setImporting] = useState(false);
const [result, setResult] = useState<{
imported: number;
skipped: number;
failed: number;
tagsAssigned: number;
} | null>(null);
function reset() {
setFile(null);
setParsedRows([]);
setHasTagsColumn(false);
setHasCompanyColumn(false);
setTagColorByKey(new Map());
setResult(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}
function handleOpenChange(open: boolean) {
if (!open) reset();
onOpenChange(open);
function handleOpenChange(next: boolean) {
if (!next) reset();
onOpenChange(next);
}
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@ -117,15 +165,42 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
setResult(null);
const text = await selected.text();
const rows = parseCSV(text);
const {
rows,
hasTagsColumn: csvHasTags,
hasCompanyColumn: csvHasCompany,
} = parseContactCsv(text);
if (rows.length === 0) {
toast.error('No valid rows found. Ensure CSV has a "phone" column header.');
toast.error(
'No valid rows found. Ensure CSV has a "phone" column header.'
);
setParsedRows([]);
setHasTagsColumn(false);
setHasCompanyColumn(false);
setTagColorByKey(new Map());
return;
}
setParsedRows(rows);
setHasTagsColumn(csvHasTags);
setHasCompanyColumn(csvHasCompany);
if (csvHasTags && accountId) {
const { data: tags } = await supabase
.from('tags')
.select('name, color')
.eq('account_id', accountId);
const colors = new Map<string, string>();
for (const tag of tags ?? []) {
const key = tag.name.trim().toLowerCase();
if (!colors.has(key)) colors.set(key, tag.color);
}
setTagColorByKey(colors);
} else {
setTagColorByKey(new Map());
}
}
async function handleImport() {
@ -138,7 +213,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} = await supabase.auth.getSession();
const user = session?.user;
if (!user) throw new Error('Not authenticated');
if (!accountId) throw new Error('Your profile is not linked to an account.');
if (!accountId)
throw new Error('Your profile is not linked to an account.');
let imported = 0;
let skipped = 0;
@ -156,8 +232,10 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
.eq('account_id', accountId);
const existing = new Set(
(existingRows ?? [])
.map((r) => (r as { phone_normalized: string | null }).phone_normalized)
.filter((p): p is string => !!p),
.map(
(r) => (r as { phone_normalized: string | null }).phone_normalized
)
.filter((p): p is string => !!p)
);
const toInsert = unique.filter((row) => {
@ -168,10 +246,27 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
return true;
});
// 3) Batch insert the genuinely-new rows in chunks of 50. The DB
// 3) Resolve tag names → ids (admin+ may auto-create missing tags).
// Skip the round-trip when the import carries no tag names.
const allTagNames = toInsert.flatMap((row) => row.tagNames);
let tagIdByKey = new Map<string, string>();
let skippedNames: string[] = [];
if (allTagNames.length > 0) {
({ tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, {
accountId,
userId: user.id,
tagNames: allTagNames,
canCreateTags: canEditSettings,
}));
}
const tagAssignments: ContactTagAssignment[] = [];
// 4) Batch insert the genuinely-new rows in chunks of 50. The DB
// unique index is the backstop: a 23505 (race, or a format
// that normalizes equal) counts as skipped, not failed.
const chunkSize = 50;
for (let i = 0; i < toInsert.length; i += chunkSize) {
const chunk = toInsert.slice(i, i + chunkSize);
const rows = chunk.map((row) => ({
@ -191,10 +286,23 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
if (error) {
// Retry individually so one bad/duplicate row doesn't sink
// the whole chunk.
for (const row of rows) {
const { error: singleErr } = await supabase.from('contacts').insert(row);
if (!singleErr) {
for (let j = 0; j < rows.length; j++) {
const row = rows[j];
const source = chunk[j];
const { data: singleData, error: singleErr } = await supabase
.from('contacts')
.insert(row)
.select('id')
.single();
if (!singleErr && singleData) {
imported++;
if (source.tagNames.length > 0) {
tagAssignments.push({
contactId: singleData.id,
tagNames: source.tagNames,
});
}
} else if (isUniqueViolation(singleErr)) {
skipped++;
} else {
@ -202,20 +310,62 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
}
}
} else {
imported += data?.length ?? chunk.length;
const inserted = data ?? [];
imported += inserted.length;
// inserted[j] ↔ chunk[j] only holds because a single INSERT
// preserves RETURNING order. If this path is ever split into
// parallel inserts, zip by phone or returned id instead.
for (let j = 0; j < inserted.length; j++) {
const source = chunk[j];
if (!source || source.tagNames.length === 0) continue;
tagAssignments.push({
contactId: inserted[j].id,
tagNames: source.tagNames,
});
}
}
}
setResult({ imported, skipped, failed });
// 5) Wire tags onto the contacts we just created. Failure here must
// not mask a successful contact import.
let tagsAssigned = 0;
try {
tagsAssigned = await assignImportedContactTags(
supabase,
tagAssignments,
tagIdByKey
);
} catch {
toast.warning('Contacts imported, but some tag assignments failed.');
}
setResult({ imported, skipped, failed, tagsAssigned });
if (imported > 0) {
toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`);
toast.success(
`${imported} contact${imported !== 1 ? 's' : ''} imported`
);
onImported();
}
if (tagsAssigned > 0) {
toast.success(
`${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`
);
}
if (skippedNames.length > 0) {
const sample = skippedNames.slice(0, 3).join(', ');
const more =
skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : '';
toast.info(
`Unknown tags skipped (create them in Settings first): ${sample}${more}`
);
}
if (skipped > 0) {
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`);
}
if (failed > 0) {
toast.error(`${failed} contact${failed !== 1 ? 's' : ''} failed to import`);
toast.error(
`${failed} contact${failed !== 1 ? 's' : ''} failed to import`
);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Import failed';
@ -225,41 +375,101 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
}
}
const preview = parsedRows.slice(0, 5);
const preview = parsedRows.slice(0, PREVIEW_LIMIT);
// Tags: OR — show when the CSV declares a column or preview rows carry
// values, so an all-empty tags column still renders for validation.
const previewHasTags =
hasTagsColumn || preview.some((row) => row.tagNames.length > 0);
// Company: AND — hide unless the CSV declares it and preview has data,
// avoiding an all-dash column that wastes horizontal space.
const previewHasCompany =
hasCompanyColumn && preview.some((row) => row.company?.trim());
const tagStats = useMemo(() => {
const names = new Set<string>();
let rowsWithTags = 0;
for (const row of parsedRows) {
if (row.tagNames.length === 0) continue;
rowsWithTags++;
for (const name of row.tagNames) names.add(name.trim().toLowerCase());
}
return { unique: names.size, rowsWithTags };
}, [parsedRows]);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg">
<DialogHeader>
<DialogTitle className="text-white">Import Contacts</DialogTitle>
<DialogDescription className="text-slate-400">
Upload a CSV file with a &quot;phone&quot; column (required). Optional columns:
name, email, company.
<DialogContent className="flex max-h-[min(90vh,720px)] flex-col gap-0 overflow-hidden border-border/80 bg-popover p-0 text-popover-foreground sm:max-w-2xl">
<div className="shrink-0 space-y-4 border-b border-border/80 px-6 pt-6 pb-5">
<DialogHeader className="gap-1.5">
<DialogTitle className="text-lg text-popover-foreground">
Import Contacts
</DialogTitle>
<DialogDescription className="leading-relaxed text-muted-foreground">
Upload a CSV with a required{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
phone
</code>{' '}
column. Optional:{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
name
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
email
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
company
</code>
,{' '}
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
tags
</code>{' '}
(comma-separated; quote multi-tag cells).
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Upload area */}
<div
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()}
className="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-700 p-6 cursor-pointer hover:border-primary/50 transition-colors"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
fileInputRef.current?.click();
}}
className={cn(
'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all',
file
? 'border-primary/35 bg-primary/[0.04]'
: 'hover:border-primary/40 border-border/80 bg-background/40 hover:bg-background/70'
)}
>
{file ? (
<>
<FileText className="size-8 text-primary" />
<p className="text-sm text-slate-300">{file.name}</p>
<p className="text-xs text-slate-500">
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} detected
<div className="bg-primary/15 ring-primary/25 flex size-10 items-center justify-center rounded-lg ring-1">
<FileText className="text-primary size-5" />
</div>
<p
className="max-w-full truncate px-2 text-sm font-medium text-popover-foreground"
title={file.name}
>
{truncateFilename(file.name)}
</p>
<span className="rounded-full bg-muted px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '}
ready
</span>
</>
) : (
<>
<Upload className="size-8 text-slate-500" />
<p className="text-sm text-slate-400">
Click to upload CSV file
<div className="flex size-10 items-center justify-center rounded-lg bg-muted/80 ring-1 ring-border/80 transition-colors group-hover:bg-muted">
<Upload className="size-5 text-muted-foreground group-hover:text-foreground" />
</div>
<p className="text-sm text-muted-foreground">
Click to choose a CSV file
</p>
<p className="text-xs text-slate-500">
CSV with &quot;phone&quot; column required
<p className="text-[11px] text-muted-foreground">
.csv up to your browser limit
</p>
</>
)}
@ -272,63 +482,136 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
onChange={handleFileChange}
className="hidden"
/>
</div>
{/* Preview table */}
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{preview.length > 0 && !result && (
<div className="space-y-2">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">
Preview (first {preview.length} rows)
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
Preview · first {preview.length}
</p>
<div className="rounded-lg border border-slate-700 overflow-hidden">
<table className="w-full text-xs">
<div className="flex flex-wrap items-center gap-1.5">
{tagStats.rowsWithTags > 0 && (
<span className="inline-flex items-center gap-1 rounded-md bg-muted/90 px-2 py-0.5 text-[11px] text-muted-foreground">
<Tag className="text-primary/80 size-3" />
{tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '}
{tagStats.rowsWithTags} contact
{tagStats.rowsWithTags !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
<div className="overflow-hidden rounded-xl border border-border ring-1 ring-border/50">
<div className="overflow-x-auto">
<table className="w-full min-w-[32rem] text-xs">
<thead>
<tr className="bg-slate-800">
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Phone</th>
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Name</th>
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Email</th>
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Company</th>
<tr className="border-b border-border bg-background/60">
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Phone
</th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Name
</th>
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Email
</th>
{previewHasCompany && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Company
</th>
)}
{previewHasTags && (
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
Tags
</th>
)}
</tr>
</thead>
<tbody>
<tbody className="divide-y divide-border/70">
{preview.map((row, i) => (
<tr key={i} className="border-t border-slate-700/50">
<td className="px-3 py-1.5 text-slate-300">{row.phone}</td>
<td className="px-3 py-1.5 text-slate-300">{row.name || '-'}</td>
<td className="px-3 py-1.5 text-slate-300">{row.email || '-'}</td>
<td className="px-3 py-1.5 text-slate-300">{row.company || '-'}</td>
<tr
key={i}
className="bg-popover/40 transition-colors hover:bg-muted/30"
>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
<PreviewCell
value={row.phone}
mono
maxWidth="max-w-[7.5rem]"
/>
</td>
<td className="px-3 py-2 text-popover-foreground">
<PreviewCell
value={row.name || '—'}
maxWidth="max-w-[8.5rem]"
/>
</td>
<td className="px-3 py-2 text-muted-foreground">
<PreviewCell
value={row.email || '—'}
maxWidth="max-w-[10rem]"
/>
</td>
{previewHasCompany && (
<td className="px-3 py-2 text-muted-foreground">
<PreviewCell
value={row.company || '—'}
maxWidth="max-w-[7rem]"
/>
</td>
)}
{previewHasTags && (
<td className="px-3 py-2 align-top">
<ImportPreviewTags
tagNames={row.tagNames}
tagColorByKey={tagColorByKey}
/>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
{parsedRows.length > 5 && (
<p className="text-xs text-slate-500">
...and {parsedRows.length - 5} more rows
</div>
{parsedRows.length > PREVIEW_LIMIT && (
<p className="text-center text-[11px] text-muted-foreground">
+ {parsedRows.length - PREVIEW_LIMIT} more row
{parsedRows.length - PREVIEW_LIMIT !== 1 ? 's' : ''} not shown
</p>
)}
</div>
)}
{/* Results */}
{result && (
<div className="rounded-lg border border-slate-700 p-4 space-y-2">
<p className="text-sm font-medium text-white">Import Complete</p>
<div className="flex flex-wrap items-center gap-4">
<div className="rounded-xl border border-border bg-background/50 p-4">
<p className="text-sm font-medium text-popover-foreground">Import complete</p>
<div className="mt-3 flex flex-wrap gap-3">
{result.imported > 0 && (
<div className="flex items-center gap-1.5 text-primary text-sm">
<CheckCircle className="size-4" />
<div className="text-primary flex items-center gap-1.5 text-sm">
<CheckCircle className="size-4 shrink-0" />
{result.imported} imported
</div>
)}
{result.tagsAssigned > 0 && (
<div className="flex items-center gap-1.5 text-sm text-cyan-400">
<CheckCircle className="size-4 shrink-0" />
{result.tagsAssigned} tag
{result.tagsAssigned !== 1 ? 's' : ''} assigned
</div>
)}
{result.skipped > 0 && (
<div className="flex items-center gap-1.5 text-amber-400 text-sm">
<AlertTriangle className="size-4" />
{result.skipped} duplicate{result.skipped !== 1 ? 's' : ''} skipped
<div className="flex items-center gap-1.5 text-sm text-amber-400">
<AlertTriangle className="size-4 shrink-0" />
{result.skipped} skipped
</div>
)}
{result.failed > 0 && (
<div className="flex items-center gap-1.5 text-red-400 text-sm">
<XCircle className="size-4" />
<div className="flex items-center gap-1.5 text-sm text-red-400">
<XCircle className="size-4 shrink-0" />
{result.failed} failed
</div>
)}
@ -337,12 +620,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
)}
</div>
<DialogFooter className="bg-slate-900 border-slate-700">
<DialogFooter className="mt-0 shrink-0 gap-2 border-t border-border/80 bg-background/50 px-6 py-4 sm:justify-end">
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-muted-foreground hover:bg-muted"
>
{result ? 'Close' : 'Cancel'}
</Button>
@ -354,7 +637,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
{importing && <Loader2 className="size-4 animate-spin" />}
Import {parsedRows.length > 0 ? `${parsedRows.length} Contacts` : ''}
Import {parsedRows.length > 0 ? parsedRows.length : ''} contact
{parsedRows.length !== 1 ? 's' : ''}
</Button>
)}
</DialogFooter>

View File

@ -54,9 +54,9 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
i === 0 || totalLoaded > PAGE_SIZES[i - 1]
return (
<section className="rounded-xl border border-slate-800 bg-slate-900">
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
<h2 className="text-sm font-semibold text-white">Recent Activity</h2>
<section className="rounded-xl border border-border bg-card">
<header className="flex items-center justify-between border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">Recent Activity</h2>
<Link
href="/inbox"
className="text-xs font-medium text-primary hover:text-primary/80"
@ -81,13 +81,14 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
</div>
) : (
<>
<ul className="divide-y divide-slate-800">
<ul className="divide-y divide-border">
{visible.map((it, i) => {
const theme = KIND_THEME[it.kind]
const Icon = theme.icon
// Alternating row background for scanability — dark-theme
// translation of the spec's white / #f9fafb stripes.
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-slate-900/40'
// Alternating row background for scanability. bg-muted/40
// keeps the stripe visible in both light and dark modes
// (bg-card/40 vanishes against a white card surface in light).
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-muted/40'
const row = (
<div className="flex items-center gap-3 px-5 py-2.5">
<span
@ -98,16 +99,16 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
>
<Icon className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-sm text-slate-200">
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
{it.text}
</span>
<span className="flex-shrink-0 text-xs text-slate-500 tabular-nums">
<span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">
{relativeTime(it.at)}
</span>
</div>
)
return (
<li key={it.id} className={cn(stripe, 'transition-colors hover:bg-slate-800/40')}>
<li key={it.id} className={cn(stripe, 'transition-colors hover:bg-muted/40')}>
{it.href ? (
<Link href={it.href} className="block">
{row}
@ -119,13 +120,13 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
)
})}
</ul>
<footer className="flex items-center justify-between border-t border-slate-800 px-5 py-3 text-xs">
<span className="text-slate-500 tabular-nums">
<footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs">
<span className="text-muted-foreground tabular-nums">
Showing {visible.length} of {totalLoaded}
{totalLoaded === 50 ? '+' : ''}
</span>
<div className="flex items-center gap-1">
<span className="mr-1 text-slate-500">Show</span>
<span className="mr-1 text-muted-foreground">Show</span>
{PAGE_SIZES.map((size, i) => {
const disabled = !isSizeUseful(size, i)
return (
@ -137,9 +138,9 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
className={cn(
'rounded-md px-2 py-1 font-medium tabular-nums transition-colors',
pageSize === size
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white',
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-slate-400',
? 'bg-secondary text-secondary-foreground'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
)}
>
{size}

View File

@ -46,13 +46,13 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
}, [data])
return (
<section className="flex h-full flex-col rounded-xl border border-slate-800 bg-slate-900">
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
<header className="flex items-center justify-between border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-white">Conversations Over Time</h2>
<p className="mt-0.5 text-xs text-slate-500">Daily message volume by direction</p>
<h2 className="text-sm font-semibold text-foreground">Conversations Over Time</h2>
<p className="mt-0.5 text-xs text-muted-foreground">Daily message volume by direction</p>
</div>
<div className="flex items-center gap-1 rounded-lg bg-slate-800/60 p-1">
<div className="flex items-center gap-1 rounded-lg bg-muted/60 p-1">
{[7, 30, 90].map((r) => (
<button
key={r}
@ -61,8 +61,8 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
range === r
? 'bg-slate-700 text-white'
: 'text-slate-400 hover:text-white',
? 'bg-secondary text-secondary-foreground'
: 'text-muted-foreground hover:text-foreground',
)}
>
{r} days
@ -85,7 +85,7 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
)}
</div>
<footer className="flex items-center gap-4 border-t border-slate-800 px-5 py-3 text-xs text-slate-400">
<footer className="flex items-center gap-4 border-t border-border px-5 py-3 text-xs text-muted-foreground">
<LegendDot color="#3b82f6" label="Incoming" />
<LegendDot color="#7c3aed" label="Outgoing" />
</footer>
@ -207,7 +207,7 @@ function LineSvg({
x2={VB_W - PADDING.right}
y1={y}
y2={y}
stroke="rgb(30 41 59)"
stroke="var(--border)"
strokeDasharray="3 3"
/>
<text
@ -215,7 +215,7 @@ function LineSvg({
y={y}
textAnchor="end"
dominantBaseline="middle"
className="fill-slate-500 text-[10px]"
className="fill-muted-foreground text-[10px]"
>
{t}
</text>
@ -231,7 +231,7 @@ function LineSvg({
x={xFor(i)}
y={VB_H - 8}
textAnchor="middle"
className="fill-slate-500 text-[10px]"
className="fill-muted-foreground text-[10px]"
>
{shortDayLabel(p.day)}
</text>
@ -265,7 +265,7 @@ function LineSvg({
x2={hoverX}
y1={PADDING.top}
y2={PADDING.top + chartH}
stroke="rgb(71 85 105)"
stroke="var(--muted-foreground)"
strokeDasharray="3 3"
/>
<circle cx={hoverX} cy={yFor(data[hover.idx].incoming)} r={3.5} fill="#3b82f6" />
@ -280,10 +280,10 @@ function LineSvg({
letterboxed viewBox percentage. */}
{hovered && hover !== null && (
<div
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-slate-700 bg-slate-950 px-2.5 py-1.5 text-[11px] shadow-lg"
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-border bg-popover px-2.5 py-1.5 text-[11px] shadow-lg"
style={{ left: `${hover.tooltipLeftPx}px` }}
>
<div className="font-medium text-white">{longDayLabel(hovered.day)}</div>
<div className="font-medium text-popover-foreground">{longDayLabel(hovered.day)}</div>
<div className="mt-1 flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-blue-300">
<span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500" />

View File

@ -22,15 +22,15 @@ export function EmptyState({
return (
<div
className={cn(
'flex h-full min-h-40 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-slate-800 bg-slate-900/40 px-4 py-6 text-center',
'flex h-full min-h-40 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-card/40 px-4 py-6 text-center',
className,
)}
>
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-800 text-slate-500">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
<Icon className="h-5 w-5" />
</div>
<p className="text-sm font-medium text-slate-300">{title}</p>
{hint && <p className="max-w-xs text-xs text-slate-500">{hint}</p>}
<p className="text-sm font-medium text-muted-foreground">{title}</p>
{hint && <p className="max-w-xs text-xs text-muted-foreground">{hint}</p>}
</div>
)
}

View File

@ -23,18 +23,18 @@ interface MetricCardProps {
export function MetricCard({ title, value, icon: Icon, delta, subtitle }: MetricCardProps) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900 p-5">
<div className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between">
<p className="text-sm font-medium text-slate-400">{title}</p>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-800 text-slate-500">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="h-4 w-4" />
</div>
</div>
<p className="mt-3 text-[28px] leading-none font-bold tabular-nums text-white">
<p className="mt-3 text-[28px] leading-none font-bold tabular-nums text-foreground">
{value}
</p>
{delta ? <DeltaRow sign={delta.sign} label={delta.label} /> : subtitle ? (
<p className="mt-2 text-sm text-slate-500">{subtitle}</p>
<p className="mt-2 text-sm text-muted-foreground">{subtitle}</p>
) : null}
</div>
)
@ -46,7 +46,7 @@ function DeltaRow({ sign, label }: { sign: number; label: string }) {
? 'text-primary'
: sign < 0
? 'text-red-400'
: 'text-slate-500'
: 'text-muted-foreground'
const Arrow = sign > 0 ? ArrowUp : sign < 0 ? ArrowDown : Minus
return (
<div className={cn('mt-2 flex items-center gap-1 text-sm', tone)}>

View File

@ -15,10 +15,10 @@ interface PipelineDonutProps {
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
return (
<section className="flex h-full flex-col rounded-xl border border-slate-800 bg-slate-900">
<header className="border-b border-slate-800 px-5 py-4">
<h2 className="text-sm font-semibold text-white">Pipeline Value</h2>
<p className="mt-0.5 text-xs text-slate-500">
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
<header className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-foreground">Pipeline Value</h2>
<p className="mt-0.5 text-xs text-muted-foreground">
Open deals by stage
</p>
</header>
@ -43,11 +43,11 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
style={{ background: s.color }}
aria-hidden
/>
<span className="flex-1 truncate text-slate-300">{s.name}</span>
<span className="text-slate-500 tabular-nums">
<span className="flex-1 truncate text-muted-foreground">{s.name}</span>
<span className="text-muted-foreground tabular-nums">
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
</span>
<span className="w-20 text-right text-slate-300 tabular-nums">
<span className="w-20 text-right text-muted-foreground tabular-nums">
{formatCurrencyShort(s.totalValue, currency)}
</span>
</li>
@ -98,7 +98,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
<div className="flex items-center justify-center">
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage">
{/* background ring */}
<circle cx={cx} cy={cy} r={r} fill="none" stroke="rgb(30 41 59)" strokeWidth={ringWidth} />
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} />
{segments.map((seg) => (
<path
key={seg.id}
@ -114,7 +114,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
x={cx}
y={cy - 6}
textAnchor="middle"
className="fill-slate-500 text-[11px]"
className="fill-muted-foreground text-[11px]"
>
Total
</text>
@ -122,7 +122,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
x={cx}
y={cy + 14}
textAnchor="middle"
className="fill-white text-[18px] font-semibold tabular-nums"
className="fill-foreground text-[18px] font-semibold tabular-nums"
>
{formatCurrencyShort(data.totalValue, currency)}
</text>

View File

@ -31,12 +31,12 @@ export function QuickActions() {
<Link
key={a.href}
href={a.href}
className="group flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-900 px-4 py-3 transition-colors hover:border-slate-700 hover:bg-slate-800/60"
className="group flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 transition-colors hover:border-border hover:bg-muted/60"
>
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-slate-800 ${a.tint}`}>
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-muted ${a.tint}`}>
<Icon className="h-4 w-4" />
</div>
<span className="text-sm font-medium text-white">{a.label}</span>
<span className="text-sm font-medium text-foreground">{a.label}</span>
</Link>
)
})}

View File

@ -44,13 +44,13 @@ export function ResponseTimeChart({
})) ?? []
return (
<section className="rounded-xl border border-slate-800 bg-slate-900">
<header className="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4">
<section className="rounded-xl border border-border bg-card">
<header className="flex items-center justify-between gap-3 border-b border-border px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-white">
<h2 className="text-sm font-semibold text-foreground">
Average First Response Time
</h2>
<p className="mt-0.5 text-xs text-slate-500">
<p className="mt-0.5 text-xs text-muted-foreground">
Minutes to reply to a customer&apos;s first unreplied message, by
weekday
</p>
@ -63,13 +63,13 @@ export function ResponseTimeChart({
)}
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
<div>
<div className="text-slate-400">
<div className="text-muted-foreground">
This week:{' '}
<span className="font-medium text-white tabular-nums">
<span className="font-medium text-foreground tabular-nums">
{fmt(data.thisWeekAvg)}
</span>
</div>
<div className="text-slate-500">
<div className="text-muted-foreground">
Last week:{' '}
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
</div>

View File

@ -6,14 +6,14 @@ import { cn } from '@/lib/utils'
* its data fetches.
*/
export function Skeleton({ className }: { className?: string }) {
return <div className={cn('animate-pulse rounded-md bg-slate-800', className)} />
return <div className={cn('animate-pulse rounded-md bg-muted', className)} />
}
export function SkeletonCard({ className }: { className?: string }) {
return (
<div
className={cn(
'rounded-xl border border-slate-800 bg-slate-900 p-5',
'rounded-xl border border-border bg-card p-5',
className,
)}
>

View File

@ -1,4 +1,4 @@
"use client";
'use client';
/**
* Linear-list flow editor.
@ -17,7 +17,7 @@
* are list-only and have no canvas analogue.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
CircleAlert,
Plus,
@ -25,39 +25,41 @@ import {
ChevronDown,
ChevronUp,
CornerDownRight,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
} from '@/components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import { type ValidationIssue } from "@/lib/flows/validate";
} from '@/components/ui/dropdown-menu';
import { cn } from '@/lib/utils';
import { type ValidationIssue } from '@/lib/flows/validate';
import {
NODE_META,
NodeIconChip,
groupNodeTypesByCategory,
nodeColors,
slugify,
summarizeNode,
type BuilderNode,
type NodeType,
} from "./shared";
import { NodeConfigForm } from "./forms/node-config-form";
import { NodeKeySelect } from "./forms/fields";
import { IssueLine } from "./validation-panel";
import {
useFlowEditor,
type BuilderState,
} from "./flow-editor-state";
} from './shared';
import { NodeConfigForm } from './forms/node-config-form';
import { NodeKeySelect } from './forms/fields';
import { IssueLine } from './validation-panel';
import { useFlowEditor, type BuilderState } from './flow-editor-state';
// ============================================================
// Local state shape — mirrors the DB but the configs are typed
@ -85,7 +87,7 @@ export function FlowBuilder() {
// jump-to-node. The flash itself is read from context (flashKey)
// so canvas + list share the same source of truth.
const [expanded, setExpanded] = useState<Set<string>>(
() => new Set(state.nodes.map((n) => n.node_key)),
() => new Set(state.nodes.map((n) => n.node_key))
);
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@ -97,7 +99,7 @@ export function FlowBuilder() {
const key = addNodeCtx(type);
setExpanded((prev) => new Set([...prev, key]));
},
[addNodeCtx],
[addNodeCtx]
);
const removeNode = useCallback(
@ -109,7 +111,7 @@ export function FlowBuilder() {
return next;
});
},
[removeNodeCtx],
[removeNodeCtx]
);
const toggleExpanded = useCallback((key: string) => {
@ -126,7 +128,7 @@ export function FlowBuilder() {
if (el) nodeRefs.current.set(key, el);
else nodeRefs.current.delete(key);
},
[],
[]
);
// React to validator jumps via the shared flashKey. We DERIVE the
@ -145,33 +147,33 @@ export function FlowBuilder() {
// committed any expand-induced layout shift.
requestAnimationFrame(() => {
const el = nodeRefs.current.get(flashKey);
el?.scrollIntoView({ behavior: "smooth", block: "center" });
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}, [flashKey]);
return (
<div className="flex flex-col gap-6">
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-7">
<TriggerPanel
state={state}
setState={setState}
triggerIssues={issues.filter((i) => i.scope === "trigger")}
triggerIssues={issues.filter((i) => i.scope === 'trigger')}
/>
<EntryPicker state={state} setState={setState} />
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-white">
<h2 className="text-foreground text-sm font-semibold">
Nodes ({state.nodes.length})
</h2>
<AddNodeButton onAdd={addNode} />
</div>
{state.nodes.length === 0 ? (
<div className="rounded-lg border border-dashed border-slate-700 bg-slate-900/50 p-8 text-center text-sm text-slate-400">
Add a <strong>Start</strong> node, then a <strong>Send buttons</strong>
{" "}node, then a <strong>Handoff</strong> — that&apos;s the welcome-menu
shape from the brief.
<div className="border-border bg-card/50 text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm">
Add a <strong>Start</strong> node, then a{' '}
<strong>Send buttons</strong> node, then a <strong>Handoff</strong>{' '}
— that&apos;s the welcome-menu shape from the brief.
</div>
) : (
state.nodes.map((node) => (
@ -184,7 +186,7 @@ export function FlowBuilder() {
isFlashed={flashKey === node.node_key}
cardRef={setNodeRef(node.node_key)}
issues={issues.filter(
(i) => i.scope === "node" && i.node_key === node.node_key,
(i) => i.scope === 'node' && i.node_key === node.node_key
)}
onToggle={() => toggleExpanded(node.node_key)}
onUpdate={(patch) => updateNode(node.node_key, patch)}
@ -201,7 +203,6 @@ export function FlowBuilder() {
);
}
// ============================================================
// Keyword trigger input
// ============================================================
@ -223,14 +224,14 @@ function KeywordsInput({
keywords: string[];
onChange: (keywords: string[]) => void;
}) {
const [draft, setDraft] = useState(keywords.join(", "));
const [draft, setDraft] = useState(keywords.join(', '));
function commit() {
const parsed = draft
.split(",")
.split(',')
.map((k) => k.trim())
.filter(Boolean);
setDraft(parsed.join(", "));
setDraft(parsed.join(', '));
onChange(parsed);
}
@ -240,13 +241,13 @@ function KeywordsInput({
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (e.key === 'Enter') {
e.preventDefault();
commit();
}
}}
placeholder="support, help, hi"
className="bg-slate-800"
className="bg-muted"
/>
);
}
@ -265,23 +266,25 @@ function TriggerPanel({
triggerIssues: ValidationIssue[];
}) {
return (
<section className="rounded-lg border border-slate-800 bg-slate-900 p-4">
<h2 className="mb-3 text-sm font-semibold text-white">Trigger</h2>
<section className="border-border bg-card rounded-lg border p-4">
<h2 className="text-foreground mb-3 text-sm font-semibold">Trigger</h2>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-slate-400">When…</label>
<label className="text-muted-foreground mb-1 block text-xs">
When…
</label>
<Select
value={state.trigger_type}
onValueChange={(v) =>
setState((s) => ({
...s,
trigger_type: v as BuilderState["trigger_type"],
trigger_type: v as BuilderState['trigger_type'],
trigger_config:
v === "keyword" ? { keywords: [] } : v === "manual" ? {} : {},
v === 'keyword' ? { keywords: [] } : v === 'manual' ? {} : {},
}))
}
>
<SelectTrigger className="bg-slate-800">
<SelectTrigger className="bg-muted">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -297,9 +300,9 @@ function TriggerPanel({
</SelectContent>
</Select>
</div>
{state.trigger_type === "keyword" && (
{state.trigger_type === 'keyword' && (
<div>
<label className="mb-1 block text-xs text-slate-400">
<label className="text-muted-foreground mb-1 block text-xs">
Keywords (comma-separated)
</label>
<KeywordsInput
@ -342,17 +345,15 @@ function EntryPicker({
}) {
if (state.nodes.length === 0) return null;
return (
<section className="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-900 p-3">
<CornerDownRight className="h-4 w-4 shrink-0 text-primary" />
<span className="text-xs text-slate-400">Entry node:</span>
<section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3">
<CornerDownRight className="text-primary h-4 w-4 shrink-0" />
<span className="text-muted-foreground text-xs">Entry node:</span>
<NodeKeySelect
value={state.entry_node_id}
nodes={state.nodes}
onChange={(key) =>
setState((s) => ({ ...s, entry_node_id: key }))
}
onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
placeholder="Pick the first node…"
className="flex-1 max-w-xs"
className="max-w-xs flex-1"
/>
</section>
);
@ -390,47 +391,55 @@ function NodeCard({
onSetEntry: () => void;
}) {
const meta = NODE_META[node.node_type];
const hasError = issues.some((i) => i.severity === "error");
const c = nodeColors(node.node_type);
const hasError = issues.some((i) => i.severity === 'error');
const preview = summarizeNode(node);
return (
<div
ref={cardRef}
className={cn(
"rounded-lg border bg-slate-900 transition-shadow duration-500",
'bg-card relative overflow-hidden rounded-xl border transition-shadow duration-500',
hasError
? "border-red-500/40"
? 'border-red-500/40'
: isEntry
? "border-primary/50"
: "border-slate-800",
isFlashed &&
"ring-2 ring-primary ring-offset-2 ring-offset-slate-950",
? 'border-primary/50'
: 'border-border',
isFlashed && 'ring-primary ring-offset-background ring-2 ring-offset-2'
)}
>
{/* type-colored left rail, ties the list row to the canvas hue */}
<span
className="absolute inset-y-0 left-0 w-[3px]"
style={{ background: c.solid }}
/>
<button
type="button"
onClick={onToggle}
className="flex w-full items-center gap-3 px-4 py-3 text-left"
className="flex w-full items-center gap-3 px-4 py-3 pl-5 text-left"
>
<meta.icon className={cn("h-4 w-4 shrink-0", meta.color)} />
<NodeIconChip type={node.node_type} size={32} iconSize={16} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-white">
<span
className="truncate text-[11px] font-semibold tracking-wider uppercase"
style={{ color: c.text }}
>
{meta.label}
</span>
<code className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400">
<code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
{node.node_key}
</code>
{isEntry && (
<Badge
variant="outline"
className="border-primary/40 bg-primary/10 text-[10px] text-primary"
className="border-primary/40 bg-primary/10 text-primary text-[10px]"
>
Entry
</Badge>
)}
</div>
{!expanded && preview && (
<p className="mt-0.5 truncate text-xs text-slate-500">
<p className="text-muted-foreground mt-0.5 truncate text-xs">
{preview}
</p>
)}
@ -439,20 +448,20 @@ function NodeCard({
<CircleAlert className="h-3.5 w-3.5 shrink-0 text-red-400" />
)}
{expanded ? (
<ChevronUp className="h-4 w-4 text-slate-500" />
<ChevronUp className="text-muted-foreground h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4 text-slate-500" />
<ChevronDown className="text-muted-foreground h-4 w-4" />
)}
</button>
{expanded && (
<div className="border-t border-slate-800 px-4 py-4">
<div className="border-border border-t px-4 py-4">
<NodeConfigWithAdvanced
node={node}
allNodes={allNodes}
onUpdate={onUpdate}
onUpdateConfig={onUpdateConfig}
/>
<div className="mt-4 flex items-center justify-between border-t border-slate-800 pt-3">
<div className="border-border mt-4 flex items-center justify-between border-t pt-3">
<div className="flex items-center gap-2">
{!isEntry && (
<Button variant="ghost" size="sm" onClick={onSetEntry}>
@ -502,7 +511,7 @@ function NodeConfigWithAdvanced({
}) {
const [showAdvanced, setShowAdvanced] = useState(false);
const hasReplyIds =
node.node_type === "send_buttons" || node.node_type === "send_list";
node.node_type === 'send_buttons' || node.node_type === 'send_list';
return (
<div className="flex flex-col gap-3">
<NodeConfigForm
@ -511,23 +520,23 @@ function NodeConfigWithAdvanced({
showAdvanced={showAdvanced}
onUpdateConfig={onUpdateConfig}
/>
<div className="border-t border-slate-800 pt-3">
<div className="border-border border-t pt-3">
<button
type="button"
onClick={() => setShowAdvanced((v) => !v)}
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
{showAdvanced ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
{showAdvanced ? "Hide" : "Show"} advanced
{showAdvanced ? 'Hide' : 'Show'} advanced
</button>
{showAdvanced && (
<div className="mt-3 flex flex-col gap-3">
<div>
<label className="mb-1 block text-xs text-slate-400">
<label className="text-muted-foreground mb-1 block text-xs">
Node key (internal identifier — keep stable for analytics)
</label>
<Input
@ -535,14 +544,14 @@ function NodeConfigWithAdvanced({
onChange={(e) =>
onUpdate({ node_key: slugify(e.target.value, node.node_key) })
}
className="bg-slate-800 font-mono text-xs"
className="bg-muted font-mono text-xs"
/>
</div>
{hasReplyIds && (
<p className="text-[10px] text-slate-500">
<p className="text-muted-foreground text-[10px]">
Reply IDs for each option are shown inline above. They&apos;re
returned by WhatsApp when a customer taps; you usually don&apos;t
need to touch them.
returned by WhatsApp when a customer taps; you usually
don&apos;t need to touch them.
</p>
)}
</div>
@ -552,45 +561,51 @@ function NodeConfigWithAdvanced({
);
}
// ============================================================
// Add-node menu
// ============================================================
function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
const types: NodeType[] = [
"start",
"send_buttons",
"send_list",
"send_message",
"send_media",
"collect_input",
"condition",
"set_tag",
"handoff",
"end",
'start',
'send_buttons',
'send_list',
'send_message',
'send_media',
'collect_input',
'condition',
'set_tag',
'handoff',
'end',
];
return (
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-800"
className="border-border bg-card text-foreground hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
aria-label="Add node"
>
<Plus className="h-3.5 w-3.5" />
Add node
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-slate-700 bg-slate-900">
{types.map((t) => {
<DropdownMenuContent align="end" className="border-border bg-popover">
{groupNodeTypesByCategory(types).map((group, i) => (
<div key={group.id}>
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
{group.label}
</DropdownMenuLabel>
{group.types.map((t) => {
const meta = NODE_META[t];
return (
<DropdownMenuItem key={t} onClick={() => onAdd(t)}>
<meta.icon className={cn("h-3.5 w-3.5", meta.color)} />
<meta.icon className={cn('h-3.5 w-3.5', meta.color)} />
{meta.label}
</DropdownMenuItem>
);
})}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@ -1,4 +1,4 @@
"use client";
'use client';
/**
* Canvas / mind-map view of a flow. Editable, in parity with the
@ -36,10 +36,11 @@
* list view reads.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
applyNodeChanges,
Background,
BackgroundVariant,
Controls,
Handle,
MiniMap,
@ -54,12 +55,12 @@ import {
type NodeChange,
type NodeProps,
type OnNodeDrag,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import { Plus, Trash2 } from "lucide-react";
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { Plus, Trash2 } from 'lucide-react';
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
@ -67,27 +68,32 @@ import {
SheetFooter,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
} from '@/components/ui/sheet';
import {
applyEdgeConnection,
deriveCanvasEdges,
outgoingSlots,
} from "@/lib/flows/edges";
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
} from '@/lib/flows/edges';
import { autoLayout, shouldAutoLayout } from '@/lib/flows/layout';
import {
NODE_META,
NodeIconChip,
groupNodeTypesByCategory,
nodeColors,
summarizeNode,
type BuilderNode,
type NodeType,
} from "./shared";
} from './shared';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useFlowEditor } from "./flow-editor-state";
import { NodeConfigForm } from "./forms/node-config-form";
} from '@/components/ui/dropdown-menu';
import { useFlowEditor } from './flow-editor-state';
import { NodeConfigForm } from './forms/node-config-form';
// React-Flow node `data` payload — the bits our custom renderer needs.
interface NodeData extends Record<string, unknown> {
@ -109,17 +115,32 @@ const NODE_HEIGHT = 90;
// view's collapsed card so the two views feel like the same product.
// ============================================================
// Echo the design's green/red branch ports for the condition node's
// true / false slots; every other slot inherits the node's own hue.
// The true/false hues are derived from the start (emerald) and handoff
// (rose) node colors so all branch/port colors stay single-sourced in
// NODE_HUE — a palette tweak there can't leave these stale.
function slotColor(nodeType: NodeType, slotId: string, fallback: string) {
if (nodeType === 'condition' && slotId === 'true') {
return nodeColors('start').solid;
}
if (nodeType === 'condition' && slotId === 'false') {
return nodeColors('handoff').solid;
}
return fallback;
}
function FlowNodeCard({ data, selected }: NodeProps) {
const { node, isEntry, isFlashed } = data as NodeData;
const meta = NODE_META[node.node_type];
const c = nodeColors(node.node_type);
const summary = summarizeNode(node);
const Icon = meta.icon;
const slots = outgoingSlots(node);
// Start nodes are entry-only; nothing ever targets them, so they
// don't need an incoming Handle. Every other node type accepts
// incoming edges (including terminal handoff / end — they're the
// common targets).
const hasTarget = node.node_type !== "start";
const hasTarget = node.node_type !== 'start';
// Single-slot nodes get a single source handle floated on the right
// edge of the card. Multi-slot nodes (condition, send_buttons,
// send_list) render slot rows inline so each handle visually sits
@ -127,51 +148,71 @@ function FlowNodeCard({ data, selected }: NodeProps) {
const isMultiSlot = slots.length > 1;
return (
<div
style={
{
'--nc': c.solid,
'--nc-soft': c.soft,
'--nc-ring': c.ring,
'--nc-text': c.text,
borderColor: selected ? c.solid : undefined,
boxShadow: selected
? `0 0 0 1px ${c.solid}, 0 14px 36px -12px ${c.ring}`
: undefined,
} as React.CSSProperties
}
className={cn(
"relative min-w-[220px] max-w-[260px] rounded-lg border bg-slate-900/95 px-3 py-2 text-left shadow-lg backdrop-blur transition-colors",
'bg-card relative max-w-[260px] min-w-[220px] rounded-xl border px-3.5 py-3 text-left shadow-[0_2px_6px_rgba(0,0,0,0.18)] transition-[box-shadow,border-color]',
selected
? "border-primary ring-1 ring-primary/40"
: "border-slate-700 hover:border-slate-600",
? 'border-[var(--nc)]'
: 'border-border hover:border-[var(--nc-ring)]',
// Flash overrides hover/selected colors briefly. Tailwind's
// built-in `animate-pulse` is too gentle; a ring with the
// amber accent matches the list view's flash semantics.
isFlashed && "!border-amber-400 ring-2 ring-amber-400/60",
isFlashed && '!border-amber-400 ring-2 ring-amber-400/60'
)}
>
{hasTarget && (
<Handle
type="target"
position={Position.Left}
className="!h-2.5 !w-2.5 !border-slate-600 !bg-slate-700"
className="!bg-card !h-2.5 !w-2.5 !border-2 !border-[var(--nc-ring)]"
/>
)}
<div className="flex items-center gap-2">
<Icon className={cn("h-3.5 w-3.5 shrink-0", meta.color)} />
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-slate-400">
<NodeIconChip
type={node.node_type}
size={24}
iconSize={14}
className="rounded-md"
/>
<span
className="truncate text-[10.5px] font-semibold tracking-wider uppercase"
style={{ color: c.text }}
>
{meta.label}
</span>
{isEntry && (
<span className="ml-auto rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-300">
<span className="border-border text-muted-foreground ml-auto rounded border px-1.5 py-0.5 text-[8.5px] font-bold tracking-[0.1em] uppercase">
Entry
</span>
)}
</div>
<div className="mt-1 truncate font-mono text-[11px] text-slate-300">
<div className="text-muted-foreground mt-2 truncate font-mono text-[11px]">
{node.node_key}
</div>
{summary && (
<div className="mt-1 line-clamp-2 text-xs text-slate-400">
<div className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
{summary}
</div>
)}
{isMultiSlot && (
<div className="mt-2 flex flex-col gap-1 border-t border-slate-800 pt-2">
<div className="border-border mt-2.5 flex flex-col gap-1 border-t pt-2.5">
{slots.map((slot) => (
<div
key={slot.id}
className="relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px] text-slate-300"
className="text-muted-foreground relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px]"
>
<span className="truncate" title={slot.label}>
{slot.label}
@ -180,11 +221,14 @@ function FlowNodeCard({ data, selected }: NodeProps) {
type="source"
id={slot.id}
position={Position.Right}
style={{
borderColor: slotColor(node.node_type, slot.id, c.solid),
}}
// Override default absolute positioning so the handle
// sits flush with the right edge of the card instead
// of floating at vertical center. The negative offset
// matches the card's px-3 + the handle's own radius.
className="!relative !right-auto !top-auto !h-2.5 !w-2.5 !translate-x-[12px] !transform-none !border-slate-600 !bg-slate-700"
className="!bg-card !relative !top-auto !right-auto !h-2.5 !w-2.5 !translate-x-[14px] !transform-none !border-2"
/>
</div>
))}
@ -196,7 +240,8 @@ function FlowNodeCard({ data, selected }: NodeProps) {
type="source"
id={slots[0].id}
position={Position.Right}
className="!h-2.5 !w-2.5 !border-slate-600 !bg-slate-700"
style={{ borderColor: c.solid }}
className="!bg-card !h-2.5 !w-2.5 !border-2"
/>
)}
</div>
@ -244,9 +289,9 @@ function FlowCanvasInner() {
const selectedNode = useMemo(
() =>
selectedNodeKey
? builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null
? (builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null)
: null,
[selectedNodeKey, builderNodes],
[selectedNodeKey, builderNodes]
);
const autoLayoutPositions = useMemo(() => {
@ -260,7 +305,7 @@ function FlowCanvasInner() {
height: NODE_HEIGHT,
})),
canvasEdges.map((e) => ({ source: e.source, target: e.target })),
{ direction: "TB" },
{ direction: 'TB' }
)
: null;
}, [builderNodes]);
@ -275,8 +320,8 @@ function FlowCanvasInner() {
persistedAutoLayoutRef.current = true;
updateNodePositions(
Object.fromEntries(
[...autoLayoutPositions].map(([key, pos]) => [key, pos]),
),
[...autoLayoutPositions].map(([key, pos]) => [key, pos])
)
);
}, [autoLayoutPositions, updateNodePositions]);
@ -285,7 +330,7 @@ function FlowCanvasInner() {
const fallback = autoLayoutPositions?.get(n.node_key);
return {
id: n.node_key,
type: "flow",
type: 'flow',
position: {
x: fallback?.x ?? n.position_x ?? 0,
y: fallback?.y ?? n.position_y ?? 0,
@ -319,11 +364,12 @@ function FlowCanvasInner() {
target: e.target,
sourceHandle: e.sourceHandle,
label: e.label,
labelStyle: { fill: "#cbd5e1", fontSize: 11 },
labelBgStyle: { fill: "#0f172a" },
// Mode-aware via CSS tokens so edge chrome flips with light/dark.
labelStyle: { fill: 'var(--muted-foreground)', fontSize: 11 },
labelBgStyle: { fill: 'var(--card)' },
labelBgPadding: [4, 2] as [number, number],
labelBgBorderRadius: 4,
style: { stroke: "#475569", strokeWidth: 1.5 },
style: { stroke: 'var(--border)', strokeWidth: 1.5 },
}));
return rfEdges;
@ -333,7 +379,7 @@ function FlowCanvasInner() {
(changes: NodeChange<RfNode<NodeData>>[]) => {
setRfNodes((nodes) => applyNodeChanges(changes, nodes));
},
[],
[]
);
// Drag-to-position: React-Flow tracks the visual drag internally and
@ -347,7 +393,7 @@ function FlowCanvasInner() {
(_event, node) => {
updateNodePosition(node.id, node.position.x, node.position.y);
},
[updateNodePosition],
[updateNodePosition]
);
// Pan to the flashed node when the validator panel requests one.
@ -369,7 +415,7 @@ function FlowCanvasInner() {
(_event: React.MouseEvent, node: RfNode<NodeData>) => {
setSelectedNodeKey(node.id);
},
[],
[]
);
// Drag-to-connect: React-Flow fires onConnect when the user drops a
@ -380,11 +426,15 @@ function FlowCanvasInner() {
// the next render — no need to maintain a separate edge list.
const handleConnect = useCallback(
(connection: Connection) => {
if (!connection.source || !connection.target || !connection.sourceHandle) {
if (
!connection.source ||
!connection.target ||
!connection.sourceHandle
) {
return;
}
const sourceNode = builderNodes.find(
(n) => n.node_key === connection.source,
(n) => n.node_key === connection.source
);
if (!sourceNode) return;
// Self-loops are a footgun (a button whose target is its own
@ -394,11 +444,11 @@ function FlowCanvasInner() {
const patch = applyEdgeConnection(
sourceNode,
connection.sourceHandle,
connection.target,
connection.target
);
if (patch) updateNodeConfig(connection.source, patch);
},
[builderNodes, updateNodeConfig],
[builderNodes, updateNodeConfig]
);
// Keyboard delete (Backspace / Delete) + drag-to-trash. React-Flow
@ -414,7 +464,7 @@ function FlowCanvasInner() {
if (selectedNodeKey === n.id) setSelectedNodeKey(null);
}
},
[removeNode, selectedNodeKey],
[removeNode, selectedNodeKey]
);
// Edge delete: clear the source node's slot rather than removing
@ -426,11 +476,11 @@ function FlowCanvasInner() {
if (!e.sourceHandle) continue;
const sourceNode = builderNodes.find((n) => n.node_key === e.source);
if (!sourceNode) continue;
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, "");
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, '');
if (patch) updateNodeConfig(e.source, patch);
}
},
[builderNodes, updateNodeConfig],
[builderNodes, updateNodeConfig]
);
// Wrapped mutators that target the currently-selected node — pass to
@ -440,7 +490,7 @@ function FlowCanvasInner() {
(patch: Record<string, unknown>) => {
if (selectedNodeKey) updateNodeConfig(selectedNodeKey, patch);
},
[selectedNodeKey, updateNodeConfig],
[selectedNodeKey, updateNodeConfig]
);
const handleDeleteSelected = useCallback(() => {
@ -456,7 +506,7 @@ function FlowCanvasInner() {
if (rfNodes.length === 0) {
return (
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-700 bg-slate-950 text-sm text-slate-500">
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-sm">
<p>No nodes yet.</p>
<CanvasAddNodeButton />
</div>
@ -465,7 +515,7 @@ function FlowCanvasInner() {
return (
<>
<div className="h-[70vh] w-full overflow-hidden rounded-lg border border-slate-800 bg-slate-950">
<div className="h-full w-full overflow-hidden">
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
@ -481,7 +531,7 @@ function FlowCanvasInner() {
onEdgesDelete={handleEdgesDelete}
// Default is "Backspace" only — accept both so Mac users
// hitting Delete (Fn+Backspace) get the same behavior.
deleteKeyCode={["Backspace", "Delete"]}
deleteKeyCode={['Backspace', 'Delete']}
nodesConnectable={true}
edgesFocusable={true}
elementsSelectable={true}
@ -491,19 +541,29 @@ function FlowCanvasInner() {
minZoom={0.2}
maxZoom={1.5}
>
<Background gap={24} size={1} color="#1e293b" />
{/* Dot grid, matching the design's faint canvas backdrop. */}
<Background
variant={BackgroundVariant.Dots}
gap={22}
size={1.4}
color="var(--border)"
/>
<Controls
className="!border-slate-700 !bg-slate-900 [&_button]:!border-slate-700 [&_button]:!bg-slate-900 [&_button:hover]:!bg-slate-800"
className="!border-border !bg-card [&_button]:!border-border [&_button]:!bg-card [&_button:hover]:!bg-muted [&_button_svg]:!fill-foreground !overflow-hidden !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
showInteractive={false}
/>
<MiniMap
pannable
zoomable
nodeColor="#334155"
maskColor="rgba(15, 23, 42, 0.7)"
className="!border !border-slate-700 !bg-slate-900"
nodeColor={(n) =>
nodeColors((n.data as NodeData).node.node_type).solid
}
nodeStrokeWidth={0}
nodeBorderRadius={3}
maskColor="color-mix(in oklch, var(--background) 70%, transparent)"
className="!border-border !bg-card !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
/>
<Panel position="bottom-right" className="!bottom-4 !right-4">
<Panel position="top-left" className="!top-4 !left-4">
<CanvasAddNodeButton />
</Panel>
</ReactFlow>
@ -556,26 +616,31 @@ function NodeEditSheet({
);
}
const meta = NODE_META[node.node_type];
const Icon = meta.icon;
const c = nodeColors(node.node_type);
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex w-full flex-col gap-0 border-l border-slate-800 bg-slate-950 p-0 sm:max-w-md"
className="border-border bg-popover flex w-full flex-col gap-0 border-l p-0 sm:max-w-md"
>
<SheetHeader className="border-b border-slate-800 px-5 py-4">
<SheetTitle className="flex items-center gap-2 text-slate-100">
<Icon className={cn("h-4 w-4 shrink-0", meta.color)} />
<span>{meta.label}</span>
<SheetHeader className="border-border flex-row items-center gap-3 space-y-0 border-b px-5 py-4">
<NodeIconChip type={node.node_type} size={36} iconSize={18} />
<div className="min-w-0 flex-1">
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold tracking-wider uppercase">
<span style={{ color: c.text }}>{meta.label}</span>
{isEntry && (
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-300">
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-emerald-300 uppercase">
Entry
</span>
)}
</SheetTitle>
<SheetDescription className="font-mono text-[11px] text-slate-400">
{node.node_key}
<SheetDescription className="text-muted-foreground mt-0.5 text-xs">
{meta.blurb}
</SheetDescription>
</div>
<code className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
{node.node_key}
</code>
</SheetHeader>
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-5 py-4">
@ -587,7 +652,7 @@ function NodeEditSheet({
/>
</div>
<SheetFooter className="border-t border-slate-800 px-5 py-3 sm:flex-row sm:justify-between">
<SheetFooter className="border-border border-t px-5 py-3 sm:flex-row sm:justify-between">
{!isEntry ? (
<Button variant="ghost" size="sm" onClick={onSetEntry}>
Set as entry
@ -618,16 +683,16 @@ function NodeEditSheet({
// ============================================================
const ADD_NODE_TYPES: NodeType[] = [
"start",
"send_buttons",
"send_list",
"send_message",
"send_media",
"collect_input",
"condition",
"set_tag",
"handoff",
"end",
'start',
'send_buttons',
'send_list',
'send_message',
'send_media',
'collect_input',
'condition',
'set_tag',
'handoff',
'end',
];
function CanvasAddNodeButton() {
@ -641,7 +706,7 @@ function CanvasAddNodeButton() {
// .react-flow root and read its bounding rect. If we can't find
// it (test envs, etc.), addNode's default (0, 0) is the fallback
// and the user can drag the node into view.
const root = document.querySelector(".react-flow") as HTMLElement | null;
const root = document.querySelector('.react-flow') as HTMLElement | null;
if (!root) return;
const rect = root.getBoundingClientRect();
const center = reactFlow.screenToFlowPosition({
@ -651,29 +716,59 @@ function CanvasAddNodeButton() {
// NODE_WIDTH / NODE_HEIGHT are the dagre layout defaults; offset
// so the card sits visually centered rather than top-left at the
// viewport center.
updateNodePosition(key, center.x - NODE_WIDTH / 2, center.y - NODE_HEIGHT / 2);
updateNodePosition(
key,
center.x - NODE_WIDTH / 2,
center.y - NODE_HEIGHT / 2
);
};
return (
<DropdownMenu>
<DropdownMenuTrigger
className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-xs font-medium text-slate-200 shadow-lg transition-colors hover:bg-slate-800"
className="bg-primary text-primary-foreground hover:bg-primary-hover inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] font-medium shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors"
aria-label="Add node"
>
<Plus className="h-3.5 w-3.5" />
<Plus className="h-4 w-4" />
Add node
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="border-slate-700 bg-slate-900">
{ADD_NODE_TYPES.map((t) => {
<DropdownMenuContent
align="start"
className="border-border bg-popover w-[268px] p-1.5"
>
{groupNodeTypesByCategory(ADD_NODE_TYPES).map((group, i) => (
<div key={group.id}>
{i > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-muted-foreground px-2 py-1.5 text-[11px] font-semibold tracking-wider uppercase">
{group.label}
</DropdownMenuLabel>
{group.types.map((t) => {
const meta = NODE_META[t];
const Icon = meta.icon;
return (
<DropdownMenuItem key={t} onClick={() => handleAdd(t)}>
<Icon className={cn("h-3.5 w-3.5", meta.color)} />
<DropdownMenuItem
key={t}
onClick={() => handleAdd(t)}
className="gap-3 py-2"
>
<NodeIconChip
type={t}
size={28}
iconSize={16}
className="rounded-md"
/>
<span className="flex flex-col">
<span className="text-popover-foreground text-[13px] font-semibold">
{meta.label}
</span>
<span className="text-muted-foreground text-[11.5px]">
{meta.blurb}
</span>
</span>
</DropdownMenuItem>
);
})}
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
);

View File

@ -1,11 +1,19 @@
"use client";
/**
* View-switcher for the flow editor.
* View-switcher + chrome for the flow editor.
*
* Renders a small Canvas / List pill above whichever view is active,
* and conditionally mounts `<FlowCanvas>` or `<FlowBuilder>`. Why a
* separate component:
* Lays the editor out as one app-like column that fills the dashboard
* content area (toolbar → mode row → stage → validation bar), matching
* the Flow Builder design handoff:
* - A segmented Canvas / List control on the left of the mode row.
* - A node-type legend on the right so the canvas's per-type colors
* are decodable at a glance.
* - The active view is mounted inside a rounded "stage" that owns its
* own scroll/overflow, so the canvas can fill available height and
* the list scrolls internally.
*
* Why a separate component:
* - The page itself stays trivially small (loading + error + this).
* - Either view can stay unaware of the other — they share data
* (`{flow, nodes}`) and nothing else.
@ -17,13 +25,14 @@
*/
import { useEffect, useState } from "react";
import { LayoutGrid, ListTree } from "lucide-react";
import { GitFork, List } from "lucide-react";
import { FlowBuilder } from "./flow-builder";
import { FlowCanvas } from "./flow-canvas";
import { FlowEditorProvider } from "./flow-editor-state";
import { EditorHeader } from "./header";
import { ValidationPanel } from "./validation-panel";
import { NODE_META, nodeColors, type NodeType } from "./shared";
import { cn } from "@/lib/utils";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
@ -39,6 +48,11 @@ type View = "canvas" | "list";
const STORAGE_KEY = "wacrm.flowEditor.view";
// Legend covers every node type, derived from NODE_META so a new type
// can't silently go undocumented. NODE_META's key order already reads
// the way a flow flows: start → talk → capture → branch → mutate → end.
const LEGEND_TYPES = Object.keys(NODE_META) as NodeType[];
interface Props {
initialFlow: FlowRow;
initialNodes: FlowNodeRow[];
@ -78,37 +92,63 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
return (
<FlowEditorProvider initialFlow={initialFlow} initialNodes={initialNodes}>
<div className="mx-auto flex h-full max-w-4xl flex-col gap-6 p-6">
<div className="flex h-full min-h-0 flex-col">
<EditorHeader />
{/* ---- mode row: view toggle + node-type legend ----
Omitted entirely on mobile (canvas is unavailable there and
the legend is lg-only), so there's no empty band above the
stage on small screens. */}
{!isMobile && (
<div className="flex items-center justify-end">
<div className="flex items-center gap-4 px-6 py-3.5">
<div
role="group"
aria-label="Editor view"
className="inline-flex items-center gap-1 rounded-md border border-slate-700 bg-slate-900 p-0.5 text-xs"
className="inline-flex gap-0.5 rounded-lg border border-border bg-muted p-0.5"
>
<ToggleButton
<SegButton
active={effectiveView === "canvas"}
onClick={() => choose("canvas")}
icon={<LayoutGrid className="h-3 w-3" />}
icon={<GitFork className="h-3.5 w-3.5" />}
label="Canvas"
/>
<ToggleButton
<SegButton
active={effectiveView === "list"}
onClick={() => choose("list")}
icon={<ListTree className="h-3 w-3" />}
icon={<List className="h-3.5 w-3.5" />}
label="List"
/>
</div>
<div className="ml-auto hidden flex-wrap items-center gap-x-3.5 gap-y-1.5 lg:flex">
{LEGEND_TYPES.map((t) => (
<span
key={t}
className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground"
>
<span
className="h-2.5 w-2.5 rounded-full"
style={{ background: nodeColors(t).solid }}
/>
{NODE_META[t].label}
</span>
))}
</div>
</div>
)}
{effectiveView === "canvas" ? <FlowCanvas /> : <FlowBuilder />}
{/* ---- stage: the active view, owning its own overflow ---- */}
<div className="relative mx-6 min-h-0 flex-1 overflow-hidden rounded-xl border border-border bg-card-2">
{effectiveView === "canvas" ? (
<FlowCanvas />
) : (
<div className="absolute inset-0 overflow-y-auto">
<FlowBuilder />
</div>
)}
</div>
{/* Sticky-bottom validation panel mirrors the placement used
when this lived inside FlowBuilder — the activate-readiness
status follows the user as they scroll, in either view. */}
<div className="sticky bottom-4 z-10 shadow-xl shadow-slate-950/60">
{/* ---- validation / activate-readiness bar ---- */}
<div className="px-6 pb-5 pt-3">
<ValidationPanel />
</div>
</div>
@ -138,7 +178,7 @@ function useMatchMedia(query: string): boolean {
return matches;
}
function ToggleButton({
function SegButton({
active,
onClick,
icon,
@ -155,10 +195,10 @@ function ToggleButton({
onClick={onClick}
aria-pressed={active}
className={cn(
"inline-flex items-center gap-1.5 rounded px-2 py-1 transition-colors",
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[12.5px] font-medium transition-colors",
active
? "bg-slate-700 text-slate-100"
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200",
? "bg-card text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{icon}

View File

@ -43,19 +43,19 @@ export function TextRow({
}) {
return (
<div>
<label className="mb-1 block text-xs text-slate-400">{label}</label>
<label className="mb-1 block text-xs text-muted-foreground">{label}</label>
{rows > 1 ? (
<Textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
className="bg-slate-800"
className="bg-muted"
/>
) : (
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
className="bg-slate-800"
className="bg-muted"
/>
)}
</div>
@ -77,7 +77,7 @@ export function NextNodeRow({
}) {
return (
<div>
<label className="mb-1 block text-xs text-slate-400">{label}</label>
<label className="mb-1 block text-xs text-muted-foreground">{label}</label>
<NodeKeySelect
value={value || null}
nodes={allNodes}
@ -110,7 +110,7 @@ export function NodeKeySelect({
value={value ?? "__none__"}
onValueChange={(v) => onChange(v === "__none__" ? null : v)}
>
<SelectTrigger className={cn("bg-slate-800", className)}>
<SelectTrigger className={cn("bg-muted", className)}>
<SelectValue placeholder={placeholder ?? "—"} />
</SelectTrigger>
<SelectContent>

View File

@ -45,7 +45,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { createClient } from "@/lib/supabase/client";
import { uploadAccountMedia, MEDIA_MAX_BYTES } from "@/lib/storage/upload-media";
import { slugify, type BuilderNode } from "../shared";
import { NextNodeRow, NodeKeySelect, TextRow } from "./fields";
@ -135,7 +135,7 @@ export function NodeConfigForm({
rows={2}
/>
<div>
<label className="mb-1 block text-xs text-slate-400">
<label className="mb-1 block text-xs text-muted-foreground">
Variable key (stored in flow_runs.vars; alphanumeric + underscore)
</label>
<Input
@ -146,11 +146,11 @@ export function NodeConfigForm({
})
}
placeholder="e.g. name, email, company"
className="bg-slate-800 font-mono text-xs"
className="bg-muted font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-slate-500">
<p className="mt-1 text-[10px] text-muted-foreground">
Interpolate in downstream prompts and handoff notes with{" "}
<code className="rounded bg-slate-800 px-1">
<code className="rounded bg-muted px-1">
{"{{vars."}
{(cfg as { var_key?: string }).var_key || "name"}
{"}}"}
@ -200,7 +200,7 @@ export function NodeConfigForm({
case "end":
return (
<p className="text-xs text-slate-500">
<p className="text-xs text-muted-foreground">
Terminal node. When the runner reaches this node the run is marked
complete. No config needed.
</p>
@ -269,7 +269,7 @@ function SendButtonsForm({
/>
<div>
<div className="mb-2 flex items-center justify-between">
<label className="text-xs text-slate-400">
<label className="text-xs text-muted-foreground">
Buttons (1–3) — each one routes to a different next node
</label>
</div>
@ -278,7 +278,7 @@ function SendButtonsForm({
<div
key={i}
className={cn(
"grid grid-cols-1 gap-2 rounded-md border border-slate-800 bg-slate-800/40 p-3",
"grid grid-cols-1 gap-2 rounded-md border border-border bg-muted/40 p-3",
showAdvanced
? "md:grid-cols-[1fr_2fr_2fr_auto]"
: "md:grid-cols-[2fr_2fr_auto]",
@ -293,14 +293,14 @@ function SendButtonsForm({
})
}
placeholder="reply_id"
className="bg-slate-800 font-mono text-xs"
className="bg-muted font-mono text-xs"
/>
)}
<Input
value={b.title}
onChange={(e) => updateButton(i, { title: e.target.value })}
placeholder="Visible title (≤20 chars)"
className="bg-slate-800"
className="bg-muted"
maxLength={20}
/>
<NodeKeySelect
@ -465,13 +465,13 @@ function SendListForm({
</div>
<div className="mt-2">
<label className="mb-2 block text-xs text-slate-400">
<label className="mb-2 block text-xs text-muted-foreground">
Rows (1–10 total across all sections)
</label>
{sections.map((section, sIdx) => (
<div
key={sIdx}
className="mb-3 rounded-md border border-slate-800 bg-slate-800/40 p-3"
className="mb-3 rounded-md border border-border bg-muted/40 p-3"
>
<div className="mb-2 flex items-center gap-2">
<Input
@ -480,7 +480,7 @@ function SendListForm({
updateSection(sIdx, { title: e.target.value })
}
placeholder={`Section ${sIdx + 1} title (optional)`}
className="bg-slate-800 text-xs"
className="bg-muted text-xs"
/>
{sections.length > 1 && (
<Button
@ -516,7 +516,7 @@ function SendListForm({
})
}
placeholder="reply_id"
className="bg-slate-800 font-mono text-xs"
className="bg-muted font-mono text-xs"
/>
)}
<Input
@ -525,7 +525,7 @@ function SendListForm({
updateRow(sIdx, rIdx, { title: e.target.value })
}
placeholder="Row title (≤24)"
className="bg-slate-800"
className="bg-muted"
maxLength={24}
/>
<NodeKeySelect
@ -614,14 +614,14 @@ function ConditionForm({
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div>
<label className="mb-1 block text-xs text-slate-400">If</label>
<label className="mb-1 block text-xs text-muted-foreground">If</label>
<Select
value={subject}
onValueChange={(v) =>
onUpdateConfig({ subject: v as ConditionCfg["subject"] })
}
>
<SelectTrigger className="bg-slate-800">
<SelectTrigger className="bg-muted">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -632,7 +632,7 @@ function ConditionForm({
</Select>
</div>
<div className="md:col-span-2">
<label className="mb-1 block text-xs text-slate-400">
<label className="mb-1 block text-xs text-muted-foreground">
{subject === "var"
? "var name"
: subject === "tag"
@ -644,7 +644,7 @@ function ConditionForm({
value={cfg.subject_key ?? ""}
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
>
<SelectTrigger className="bg-slate-800">
<SelectTrigger className="bg-muted">
<SelectValue placeholder="Pick a tag…" />
</SelectTrigger>
<SelectContent>
@ -660,7 +660,7 @@ function ConditionForm({
value={cfg.subject_key ?? ""}
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
>
<SelectTrigger className="bg-slate-800">
<SelectTrigger className="bg-muted">
<SelectValue placeholder="Pick a field…" />
</SelectTrigger>
<SelectContent>
@ -677,7 +677,7 @@ function ConditionForm({
onUpdateConfig({ subject_key: e.target.value })
}
placeholder={subject === "var" ? "e.g. email" : "tag UUID"}
className="bg-slate-800 font-mono text-xs"
className="bg-muted font-mono text-xs"
/>
)}
</div>
@ -690,14 +690,14 @@ function ConditionForm({
)}
>
<div>
<label className="mb-1 block text-xs text-slate-400">Operator</label>
<label className="mb-1 block text-xs text-muted-foreground">Operator</label>
<Select
value={operator}
onValueChange={(v) =>
onUpdateConfig({ operator: v as ConditionCfg["operator"] })
}
>
<SelectTrigger className="bg-slate-800">
<SelectTrigger className="bg-muted">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -710,11 +710,11 @@ function ConditionForm({
</div>
{showValue && (
<div>
<label className="mb-1 block text-xs text-slate-400">Value</label>
<label className="mb-1 block text-xs text-muted-foreground">Value</label>
<Input
value={cfg.value ?? ""}
onChange={(e) => onUpdateConfig({ value: e.target.value })}
className="bg-slate-800"
className="bg-muted"
/>
</div>
)}
@ -767,14 +767,14 @@ function SetTagForm({
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-slate-400">Action</label>
<label className="mb-1 block text-xs text-muted-foreground">Action</label>
<Select
value={cfg.mode ?? "add"}
onValueChange={(v) =>
onUpdateConfig({ mode: v as SetTagCfg["mode"] })
}
>
<SelectTrigger className="bg-slate-800">
<SelectTrigger className="bg-muted">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -784,13 +784,13 @@ function SetTagForm({
</Select>
</div>
<div>
<label className="mb-1 block text-xs text-slate-400">Tag</label>
<label className="mb-1 block text-xs text-muted-foreground">Tag</label>
{tags.length > 0 ? (
<Select
value={cfg.tag_id ?? ""}
onValueChange={(v) => onUpdateConfig({ tag_id: v })}
>
<SelectTrigger className="bg-slate-800">
<SelectTrigger className="bg-muted">
<SelectValue placeholder="Pick a tag…" />
</SelectTrigger>
<SelectContent>
@ -806,7 +806,7 @@ function SetTagForm({
value={cfg.tag_id ?? ""}
onChange={(e) => onUpdateConfig({ tag_id: e.target.value })}
placeholder="Tag UUID"
className="bg-slate-800 font-mono text-xs"
className="bg-muted font-mono text-xs"
/>
)}
</div>
@ -872,8 +872,6 @@ const MEDIA_ACCEPT: Record<NonNullable<SendMediaCfg["media_type"]>, string> = {
};
const FLOW_MEDIA_BUCKET = "flow-media";
// 16 MB — matches the bucket file_size_limit set in migration 016.
const FLOW_MEDIA_MAX_BYTES = 16 * 1024 * 1024;
function SendMediaForm({
cfg,
@ -897,7 +895,7 @@ function SendMediaForm({
const handleFile = useCallback(
async (file: File) => {
if (file.size > FLOW_MEDIA_MAX_BYTES) {
if (file.size > MEDIA_MAX_BYTES) {
toast.error(
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`,
);
@ -905,49 +903,9 @@ function SendMediaForm({
}
setUploading(true);
try {
const supabase = createClient();
const {
data: { user },
error: userErr,
} = await supabase.auth.getUser();
if (userErr || !user) {
throw new Error("Not signed in.");
}
// Resolve the caller's account_id so the path is account-
// scoped rather than user-scoped. The 020 migration's RLS
// policy allows any account member to write under
// `account-<account_id>/...`, so a flow's media survives a
// teammate leaving the account (which was a data-integrity
// hole in the original 016 storage policies).
const { data: profile, error: profileErr } = await supabase
.from("profiles")
.select("account_id")
.eq("user_id", user.id)
.maybeSingle();
if (profileErr || !profile?.account_id) {
throw new Error("Could not resolve your account.");
}
// Path convention matches migration 020's RLS policy: first
// segment is `account-<uuid>`. Timestamp + random suffix
// prevents collisions between two builders open at once.
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
const safeBase =
file.name
.replace(/\.[^.]+$/, "")
.replace(/[^a-zA-Z0-9_-]+/g, "_")
.slice(0, 40) || "file";
const path = `account-${profile.account_id}/${Date.now()}-${safeBase}.${ext}`;
const { error: upErr } = await supabase.storage
.from(FLOW_MEDIA_BUCKET)
.upload(path, file, {
cacheControl: "3600",
upsert: false,
contentType: file.type,
});
if (upErr) throw new Error(upErr.message);
const {
data: { publicUrl },
} = supabase.storage.from(FLOW_MEDIA_BUCKET).getPublicUrl(path);
// Account-scoped upload (path `account-<id>/...`) — see
// uploadAccountMedia + migration 020's flow-media RLS policy.
const { publicUrl } = await uploadAccountMedia(FLOW_MEDIA_BUCKET, file);
// Patch all fields in one call so the form doesn't re-render
// with a half-uploaded state.
onUpdateConfig({
@ -972,7 +930,7 @@ function SendMediaForm({
return (
<>
<div>
<label className="mb-1 block text-xs text-slate-400">Media type</label>
<label className="mb-1 block text-xs text-muted-foreground">Media type</label>
<Select
value={mediaType}
onValueChange={(v) => {
@ -986,7 +944,7 @@ function SendMediaForm({
});
}}
>
<SelectTrigger className="bg-slate-800">
<SelectTrigger className="bg-muted">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -1000,15 +958,15 @@ function SendMediaForm({
</div>
<div>
<label className="mb-1 block text-xs text-slate-400">File</label>
<label className="mb-1 block text-xs text-muted-foreground">File</label>
{cfg.media_url ? (
<div className="flex items-center gap-2 rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-xs">
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs">
<Paperclip className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
<a
href={cfg.media_url}
target="_blank"
rel="noopener noreferrer"
className="min-w-0 flex-1 truncate text-slate-200 hover:text-cyan-300"
className="min-w-0 flex-1 truncate text-foreground hover:text-cyan-300"
title={displayName || cfg.media_url}
>
{displayName || cfg.media_url}
@ -1016,7 +974,7 @@ function SendMediaForm({
<button
type="button"
onClick={handleClear}
className="rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-slate-200"
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
aria-label="Remove file"
disabled={uploading}
>
@ -1028,7 +986,7 @@ function SendMediaForm({
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed border-slate-700 bg-slate-900 px-3 py-4 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:bg-slate-800 hover:text-slate-200 disabled:cursor-not-allowed disabled:opacity-60"
className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed border-border bg-card px-3 py-4 text-xs text-muted-foreground transition-colors hover:border-border hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
>
{uploading ? (
<>
@ -1066,14 +1024,14 @@ function SendMediaForm({
{isDocument && (
<div>
<label className="mb-1 block text-xs text-slate-400">
<label className="mb-1 block text-xs text-muted-foreground">
Filename shown to the customer (documents only)
</label>
<Input
value={cfg.filename ?? ""}
onChange={(e) => onUpdateConfig({ filename: e.target.value })}
placeholder="invoice.pdf"
className="bg-slate-800 text-xs"
className="bg-muted text-xs"
/>
</div>
)}

View File

@ -1,11 +1,18 @@
"use client";
/**
* Editor header — flow name / description, status badge, dirty
* Editor toolbar — flow name / description, status chip, dirty
* indicator, and the action buttons (Save, Activate/Pause, Delete,
* View runs, Back).
*
* Lifted out of flow-builder.tsx so the same header renders above
* Restyled to the Flow Builder design handoff: a single compact
* toolbar row (back · icon · inline-editable name · status chip ·
* edited dot on the left; Runs · Delete · Activate · Save on the
* right) followed by a subtle, full-width description "note" line.
* Replaces the old three-row stack so the editor reads as one app
* chrome bar above the canvas/list stage.
*
* Lifted out of flow-builder.tsx so the same toolbar renders above
* both views in FlowEditorShell. Without this, canvas users had no
* way to save without toggling to list view.
*
@ -18,6 +25,7 @@
import { useRouter } from "next/navigation";
import {
ArrowLeft,
CircleDot,
History,
Loader2,
PauseCircle,
@ -27,9 +35,7 @@ import {
Workflow,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
useFlowEditor,
@ -52,32 +58,33 @@ export function EditorHeader() {
} = useFlowEditor();
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-xs text-slate-500">
<div className="flex flex-col gap-1.5 px-6 pt-5">
<div className="flex flex-wrap items-center gap-3">
{/* ---- left: back · icon · name · status · edited ---- */}
<button
type="button"
onClick={() => router.push("/flows")}
className="inline-flex items-center gap-1 hover:text-slate-300"
title="Back to Flows"
aria-label="Back to Flows"
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<ArrowLeft className="h-3 w-3" />
Flows
<ArrowLeft className="h-4 w-4" />
</button>
</div>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-3">
<Workflow className="h-5 w-5 shrink-0 text-primary" />
<Input
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-soft text-primary">
<Workflow className="h-[18px] w-[18px]" />
</span>
<input
value={state.name}
onChange={(e) =>
setState((s) => ({ ...s, name: e.target.value }))
}
onChange={(e) => setState((s) => ({ ...s, name: e.target.value }))}
placeholder="Flow name"
className="max-w-md bg-slate-900 text-lg font-semibold"
spellCheck={false}
aria-label="Flow name"
className="min-w-[120px] max-w-[340px] rounded-lg border border-transparent bg-transparent px-2 py-1 text-lg font-bold leading-tight tracking-tight text-foreground outline-none transition-colors hover:bg-muted focus:border-primary focus:bg-transparent focus:shadow-[0_0_0_3px_var(--primary-soft)]"
/>
<StatusBadge status={state.status} />
<StatusChip status={state.status} />
{dirty && (
<span
className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-amber-300"
className="inline-flex shrink-0 items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
title="Unsaved changes — hit Save to persist"
aria-live="polite"
>
@ -85,8 +92,9 @@ export function EditorHeader() {
Edited
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{/* ---- right: runs · delete · activate · save ---- */}
<div className="ml-auto flex flex-wrap items-center gap-1.5">
<Button
variant="ghost"
size="sm"
@ -94,6 +102,9 @@ export function EditorHeader() {
>
<History className="h-3.5 w-3.5" />
Runs
<span className="ml-0.5 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
{flow.execution_count}
</span>
</Button>
<Button
variant="ghost"
@ -148,27 +159,47 @@ export function EditorHeader() {
</Button>
</div>
</div>
<Input
{/* ---- description note (subtle, inline-editable) ---- */}
<input
value={state.description}
onChange={(e) =>
setState((s) => ({ ...s, description: e.target.value }))
}
placeholder="Optional description (internal — customers don't see this)"
className="bg-slate-900 text-sm"
placeholder="Add a short description (internal — customers don't see this)"
aria-label="Flow description"
className="w-full max-w-[78ch] rounded-md border border-transparent bg-transparent px-2 py-1 text-[13px] text-muted-foreground outline-none transition-colors placeholder:text-muted-foreground/60 hover:bg-muted/50 focus:border-primary focus:bg-transparent focus:text-foreground"
/>
</div>
);
}
function StatusBadge({ status }: { status: BuilderState["status"] }) {
const cls = {
draft: "border-slate-700 bg-slate-800 text-slate-300",
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
archived: "border-slate-700 bg-slate-800/50 text-slate-500",
function StatusChip({ status }: { status: BuilderState["status"] }) {
const cfg = {
draft: {
// Neutral, not amber — amber is reserved for the adjacent
// "Edited" dirty signal, so the two don't read as the same alert.
cls: "border-border bg-muted text-muted-foreground",
label: "Draft",
},
active: {
cls: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
label: "Active",
},
archived: {
cls: "border-border bg-muted/50 text-muted-foreground",
label: "Archived",
},
}[status];
return (
<Badge variant="outline" className={cn("shrink-0", cls)}>
{status.charAt(0).toUpperCase() + status.slice(1)}
</Badge>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11.5px] font-medium",
cfg.cls,
)}
>
<CircleDot className="h-3 w-3" />
{cfg.label}
</span>
);
}

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
NODE_CATEGORIES,
NODE_META,
groupNodeTypesByCategory,
type NodeType,
} from './shared';
const ALL_TYPES = Object.keys(NODE_META) as NodeType[];
describe('node categories', () => {
it('assigns every node type to a known category', () => {
const known = new Set(NODE_CATEGORIES.map((c) => c.id));
for (const type of ALL_TYPES) {
expect(known.has(NODE_META[type].category)).toBe(true);
}
});
});
describe('groupNodeTypesByCategory', () => {
it('keeps the categories in NODE_CATEGORIES order and drops empty ones', () => {
// Only messaging + flow types — the logic group must not appear.
const groups = groupNodeTypesByCategory(['send_message', 'start', 'end']);
expect(groups.map((g) => g.id)).toEqual(['messaging', 'flow']);
});
it('preserves the input order within a category', () => {
const groups = groupNodeTypesByCategory([
'send_media',
'send_message',
'send_buttons',
]);
expect(groups).toHaveLength(1);
expect(groups[0].types).toEqual([
'send_media',
'send_message',
'send_buttons',
]);
});
it('partitions the full type list without losing or duplicating a type', () => {
const grouped = groupNodeTypesByCategory(ALL_TYPES).flatMap((g) => g.types);
expect([...grouped].sort()).toEqual([...ALL_TYPES].sort());
});
});

View File

@ -28,7 +28,9 @@ import {
Tag,
UserPlus,
Workflow,
} from "lucide-react";
} from 'lucide-react';
import { cn } from '@/lib/utils';
// ============================================================
// Node-type union — single source of truth for every place the UI
@ -39,16 +41,16 @@ import {
// ============================================================
export type NodeType =
| "start"
| "send_message"
| "send_buttons"
| "send_list"
| "send_media"
| "collect_input"
| "condition"
| "set_tag"
| "handoff"
| "end";
| 'start'
| 'send_message'
| 'send_buttons'
| 'send_list'
| 'send_media'
| 'collect_input'
| 'condition'
| 'set_tag'
| 'handoff'
| 'end';
export interface BuilderNode {
node_key: string;
@ -65,54 +67,212 @@ export interface BuilderNode {
// the user sees a node summary.
// ============================================================
// ------------------------------------------------------------
// Node categories — buckets the add-step menu groups types under so
// the picker stays scannable as the type list grows, and so a fork
// adding its own node types has an obvious place to slot them.
//
// Note there's no "Events / Triggers" category: in wacrm a flow is
// triggered by flow-level config (`trigger_type`), not by a node on
// the canvas, so `start` is just the entry point under Flow control.
// ------------------------------------------------------------
export type NodeCategory = 'messaging' | 'logic' | 'flow';
/** Category labels + the order they render in the add-step menu. */
export const NODE_CATEGORIES: { id: NodeCategory; label: string }[] = [
{ id: 'messaging', label: 'Messaging' },
{ id: 'logic', label: 'Logic & data' },
{ id: 'flow', label: 'Flow control' },
];
export const NODE_META: Record<
NodeType,
{ label: string; icon: typeof Workflow; color: string }
{
label: string;
icon: typeof Workflow;
color: string;
blurb: string;
category: NodeCategory;
}
> = {
start: { label: "Start", icon: PlayCircle, color: "text-emerald-400" },
start: {
label: 'Start',
icon: PlayCircle,
color: 'text-emerald-400',
blurb: 'Entry point of the flow',
category: 'flow',
},
send_message: {
label: "Send message",
label: 'Send message',
icon: MessageCircle,
color: "text-sky-400",
color: 'text-sky-400',
blurb: 'Sends a WhatsApp text message',
category: 'messaging',
},
send_buttons: {
label: "Send buttons",
label: 'Send buttons',
icon: ListChecks,
color: "text-primary",
color: 'text-primary',
blurb: 'Sends quick-reply buttons',
category: 'messaging',
},
send_list: {
label: "Send list",
label: 'Send list',
icon: ListPlus,
color: "text-indigo-400",
color: 'text-indigo-400',
blurb: 'Sends a tappable list of options',
category: 'messaging',
},
send_media: {
label: "Send media",
label: 'Send media',
icon: Paperclip,
color: "text-cyan-400",
color: 'text-cyan-400',
blurb: 'Sends an image, video, or document',
category: 'messaging',
},
collect_input: {
label: "Collect input",
label: 'Collect input',
icon: Inbox,
color: "text-teal-400",
color: 'text-teal-400',
blurb: 'Asks a question, saves the reply',
category: 'logic',
},
condition: {
label: "If / else",
label: 'If / else',
icon: GitFork,
color: "text-fuchsia-400",
color: 'text-fuchsia-400',
blurb: 'Branches on a rule',
category: 'logic',
},
set_tag: {
label: "Tag contact",
label: 'Tag contact',
icon: Tag,
color: "text-pink-400",
color: 'text-pink-400',
blurb: 'Adds or removes a contact tag',
category: 'logic',
},
handoff: {
label: "Handoff to agent",
label: 'Handoff to agent',
icon: UserPlus,
color: "text-amber-400",
color: 'text-amber-400',
blurb: 'Hands the conversation to a human',
category: 'flow',
},
end: {
label: 'End',
icon: Flag,
color: 'text-muted-foreground',
blurb: 'Ends the flow',
category: 'flow',
},
end: { label: "End", icon: Flag, color: "text-slate-400" },
};
/**
* Bucket an ordered list of node types by category, preserving both
* the category order (NODE_CATEGORIES) and the within-category order
* of the input list. Empty categories are dropped. Used by both the
* canvas and list add-step menus so they stay in lockstep.
*/
export function groupNodeTypesByCategory(
types: NodeType[]
): { id: NodeCategory; label: string; types: NodeType[] }[] {
return NODE_CATEGORIES.map(({ id, label }) => ({
id,
label,
types: types.filter((t) => NODE_META[t].category === id),
})).filter((group) => group.types.length > 0);
}
// ============================================================
// Per-node-type color system.
//
// Each node type gets its own hue so the canvas reads at a glance —
// what KIND of step is this. Kept as raw oklch (not Tailwind classes)
// so a node card can tint its icon chip, type label, selection ring,
// and edge ports from one source, the way the Flow Builder design
// handoff does. Hues sit in the same oklch family as the app tokens
// in globals.css; they don't replace --primary (the accent), they
// complement it. `nodeColors()` derives the soft/ring/text variants.
// ============================================================
const NODE_HUE: Record<NodeType, { l: number; c: number; h: number }> = {
start: { l: 0.62, c: 0.13, h: 162 }, // emerald — the start, echoes WhatsApp green
send_message: { l: 0.6, c: 0.18, h: 293 }, // violet — the workhorse
send_buttons: { l: 0.62, c: 0.16, h: 254 }, // cobalt
send_list: { l: 0.62, c: 0.15, h: 277 }, // indigo
send_media: { l: 0.65, c: 0.12, h: 210 }, // sky
collect_input: { l: 0.65, c: 0.1, h: 185 }, // teal — capture
condition: { l: 0.72, c: 0.15, h: 65 }, // amber — a fork in the road
set_tag: { l: 0.65, c: 0.15, h: 350 }, // pink
handoff: { l: 0.65, c: 0.17, h: 16 }, // rose — hands off
end: { l: 0.55, c: 0.01, h: 260 }, // neutral grey — terminal
};
export interface NodeColors {
/** Full-strength hue — icon glyph, selection ring, port fill. */
solid: string;
/** ~14% tint — icon chip background, soft fills. */
soft: string;
/** ~45% tint — hover border / focus ring. */
ring: string;
/** Hue for the uppercase type label, kept readable in BOTH modes. */
text: string;
}
export function nodeColors(type: NodeType): NodeColors {
const t = NODE_HUE[type];
const solid = `oklch(${t.l} ${t.c} ${t.h})`;
return {
solid,
soft: `oklch(${t.l} ${t.c} ${t.h} / 0.14)`,
ring: `oklch(${t.l} ${t.c} ${t.h} / 0.45)`,
// Blend the hue toward the live --foreground token so the label
// holds contrast in BOTH modes: in dark mode --foreground is
// near-white (the label lightens to read on the dark card), in
// light mode it's near-black (the label darkens to read on the
// white card). The old fixed-light value only worked on dark.
text: `color-mix(in oklch, ${solid}, var(--foreground) 38%)`,
};
}
// ============================================================
// Shared node icon chip — the per-type colored glyph badge used in
// the canvas node card, list-view card, inspector header, and the
// add-step menu. One component so a styling change (radius, contrast,
// hover) lands in every place at once and the `nodeColors()` lookup
// lives in exactly one spot.
// ============================================================
export function NodeIconChip({
type,
size = 24,
iconSize = 14,
className,
}: {
type: NodeType;
/** Chip side length in px. */
size?: number;
/** Glyph side length in px. */
iconSize?: number;
className?: string;
}) {
const meta = NODE_META[type];
const c = nodeColors(type);
const Icon = meta.icon;
return (
<span
className={cn(
'flex shrink-0 items-center justify-center rounded-lg',
className
)}
style={{ width: size, height: size, background: c.soft, color: c.solid }}
>
<Icon size={iconSize} />
</span>
);
}
// ============================================================
// Pure editing helpers — used by forms in both views.
// ============================================================
@ -127,8 +287,8 @@ export function slugify(s: string, fallback: string): string {
const cleaned = s
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '');
return cleaned || fallback;
}
@ -140,37 +300,39 @@ export function slugify(s: string, fallback: string): string {
// ============================================================
export function truncate(s: string, max = 80): string {
const clean = s.replace(/\s+/g, " ").trim();
const clean = s.replace(/\s+/g, ' ').trim();
if (clean.length <= max) return clean;
return clean.slice(0, max - 1) + "…";
return clean.slice(0, max - 1) + '…';
}
export function summarizeNode(node: BuilderNode): string | null {
const cfg = node.config;
switch (node.node_type) {
case "start":
case "end":
case 'start':
case 'end':
return null;
case "send_message": {
const text = typeof cfg.text === "string" ? cfg.text : "";
case 'send_message': {
const text = typeof cfg.text === 'string' ? cfg.text : '';
return text.length > 0 ? truncate(text) : null;
}
case "send_buttons": {
const text = typeof cfg.text === "string" ? cfg.text : "";
case 'send_buttons': {
const text = typeof cfg.text === 'string' ? cfg.text : '';
const buttons = Array.isArray(cfg.buttons)
? (cfg.buttons as Array<Record<string, unknown>>)
: [];
const titles = buttons
.map((b) => (typeof b.title === "string" ? b.title : ""))
.map((b) => (typeof b.title === 'string' ? b.title : ''))
.filter(Boolean)
.join(" / ");
.join(' / ');
if (text.length > 0) {
return titles ? `${truncate(text, 40)} · ${truncate(titles, 35)}` : truncate(text);
return titles
? `${truncate(text, 40)} · ${truncate(titles, 35)}`
: truncate(text);
}
return titles || null;
}
case "send_list": {
const text = typeof cfg.text === "string" ? cfg.text : "";
case 'send_list': {
const text = typeof cfg.text === 'string' ? cfg.text : '';
const sections = Array.isArray(cfg.sections)
? (cfg.sections as Array<Record<string, unknown>>)
: [];
@ -180,75 +342,81 @@ export function summarizeNode(node: BuilderNode): string | null {
}, 0);
if (text.length > 0) {
return rowCount > 0
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? "" : "s"}`
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? '' : 's'}`
: truncate(text);
}
return rowCount > 0
? `${rowCount} option${rowCount === 1 ? "" : "s"} across ${sections.length} section${sections.length === 1 ? "" : "s"}`
? `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}`
: null;
}
case "send_media": {
case 'send_media': {
const mediaType =
typeof cfg.media_type === "string" ? cfg.media_type : "";
const filename = typeof cfg.filename === "string" ? cfg.filename : "";
const url = typeof cfg.media_url === "string" ? cfg.media_url : "";
const caption = typeof cfg.caption === "string" ? cfg.caption : "";
typeof cfg.media_type === 'string' ? cfg.media_type : '';
const filename = typeof cfg.filename === 'string' ? cfg.filename : '';
const url = typeof cfg.media_url === 'string' ? cfg.media_url : '';
const caption = typeof cfg.caption === 'string' ? cfg.caption : '';
const label = mediaType
? mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
: "Media";
: 'Media';
if (!url) return `${label} (no file uploaded)`;
const name = filename || url.split("/").pop() || "file";
const name = filename || url.split('/').pop() || 'file';
return caption
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
: `${label}: ${truncate(name, 60)}`;
}
case "collect_input": {
const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : "";
const varKey = typeof cfg.var_key === "string" ? cfg.var_key : "";
case 'collect_input': {
const prompt = typeof cfg.prompt_text === 'string' ? cfg.prompt_text : '';
const varKey = typeof cfg.var_key === 'string' ? cfg.var_key : '';
if (prompt.length > 0) {
return varKey ? `${truncate(prompt, 50)} → vars.${varKey}` : truncate(prompt);
return varKey
? `${truncate(prompt, 50)} → vars.${varKey}`
: truncate(prompt);
}
return varKey ? `→ vars.${varKey}` : null;
}
case "condition": {
case 'condition': {
const subjectKey =
typeof cfg.subject_key === "string" ? cfg.subject_key : "";
typeof cfg.subject_key === 'string' ? cfg.subject_key : '';
if (!subjectKey) return null;
const subject =
cfg.subject === "tag"
? "tag"
: cfg.subject === "contact_field"
? "field"
: "var";
cfg.subject === 'tag'
? 'tag'
: cfg.subject === 'contact_field'
? 'field'
: 'var';
const subjectStr =
subject === "tag" ? `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`;
subject === 'tag'
? `has tag ${truncate(subjectKey, 24)}`
: `${subject}.${subjectKey}`;
const op =
cfg.operator === "equals"
? "=="
: cfg.operator === "contains"
? "contains"
: cfg.operator === "present"
? "exists"
: cfg.operator === "absent"
? "missing"
: "";
const value = typeof cfg.value === "string" ? cfg.value : "";
cfg.operator === 'equals'
? '=='
: cfg.operator === 'contains'
? 'contains'
: cfg.operator === 'present'
? 'exists'
: cfg.operator === 'absent'
? 'missing'
: '';
const value = typeof cfg.value === 'string' ? cfg.value : '';
const valStr =
(cfg.operator === "equals" || cfg.operator === "contains") && value
(cfg.operator === 'equals' || cfg.operator === 'contains') && value
? ` "${truncate(value, 20)}"`
: "";
return subject === "tag" ? subjectStr : `${subjectStr} ${op}${valStr}`;
: '';
return subject === 'tag' ? subjectStr : `${subjectStr} ${op}${valStr}`;
}
case "set_tag": {
const mode = cfg.mode === "remove" ? "Remove" : "Add";
const tagId = typeof cfg.tag_id === "string" ? cfg.tag_id : "";
case 'set_tag': {
const mode = cfg.mode === 'remove' ? 'Remove' : 'Add';
const tagId = typeof cfg.tag_id === 'string' ? cfg.tag_id : '';
// No tag name available without an async lookup here; show a
// short prefix of the UUID so users can disambiguate between
// multiple set_tag nodes at a glance.
return tagId ? `${mode} tag ${tagId.slice(0, 8)}…` : `${mode} tag (none picked)`;
return tagId
? `${mode} tag ${tagId.slice(0, 8)}…`
: `${mode} tag (none picked)`;
}
case "handoff": {
const note = typeof cfg.note === "string" ? cfg.note : "";
case 'handoff': {
const note = typeof cfg.note === 'string' ? cfg.note : '';
return note.length > 0 ? truncate(note) : null;
}
}

View File

@ -30,7 +30,7 @@ export function ValidationPanel() {
// sticky-positioned over scrolled-behind node cards (a translucent
// bg-emerald-500/10 would bleed through ugly).
return (
<div className="flex items-center gap-2 rounded-lg border border-emerald-600/50 bg-slate-950 p-3 text-sm font-medium text-emerald-300">
<div className="flex items-center gap-2 rounded-lg border border-emerald-600/50 bg-background p-3 text-sm font-medium text-emerald-300">
<CircleCheck className="h-4 w-4 shrink-0" />
No issues. Ready to activate.
</div>
@ -41,11 +41,11 @@ export function ValidationPanel() {
return (
<div
className={cn(
"rounded-lg border bg-slate-950 p-3",
"rounded-lg border bg-background p-3",
errors.length > 0 ? "border-red-500/40" : "border-amber-500/40",
)}
>
<div className="mb-2 flex items-center gap-2 text-xs text-slate-400">
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
{errors.length > 0 ? (
<CircleAlert className="h-4 w-4 text-red-400" />
) : (
@ -85,7 +85,7 @@ export function IssueLine({
<CircleAlert className={cn("mt-0.5 h-3 w-3 shrink-0", iconTone)} />
<span className="min-w-0 flex-1">
{issue.node_key && (
<code className="mr-1 rounded bg-slate-800 px-1 py-0.5 text-[10px] text-slate-400">
<code className="mr-1 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
{issue.node_key}
</code>
)}
@ -103,7 +103,7 @@ export function IssueLine({
type="button"
onClick={() => onJump(issue.node_key!)}
className={cn(
"flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-slate-800/60",
"flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-muted/60",
tone,
)}
aria-label={`Jump to node ${issue.node_key}`}

View File

@ -117,8 +117,8 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
if (!contact) {
return (
<div className="flex h-full w-70 items-center justify-center border-l border-slate-800 bg-slate-900">
<p className="text-sm text-slate-500">Select a conversation</p>
<div className="flex h-full w-70 items-center justify-center border-l border-border bg-card">
<p className="text-sm text-muted-foreground">Select a conversation</p>
</div>
);
}
@ -127,12 +127,12 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
const initials = displayName.charAt(0).toUpperCase();
return (
<div className="flex h-full w-70 flex-col border-l border-slate-800 bg-slate-900">
<div className="flex h-full w-70 flex-col border-l border-border bg-card">
<ScrollArea className="flex-1">
<div className="p-4">
{/* Contact Info */}
<div className="flex flex-col items-center text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-700 text-lg font-semibold text-white">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted text-lg font-semibold text-foreground">
{contact.avatar_url ? (
<img
src={contact.avatar_url}
@ -143,11 +143,11 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
initials
)}
</div>
<h3 className="mt-3 text-sm font-semibold text-white">
<h3 className="mt-3 text-sm font-semibold text-foreground">
{displayName}
</h3>
{contact.company && (
<p className="text-xs text-slate-400">{contact.company}</p>
<p className="text-xs text-muted-foreground">{contact.company}</p>
)}
</div>
@ -155,37 +155,37 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
<div className="mt-4 space-y-2">
<button
onClick={handleCopyPhone}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-800"
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted"
>
<Phone className="h-4 w-4 text-slate-500" />
<Phone className="h-4 w-4 text-muted-foreground" />
<span className="flex-1 text-left">{contact.phone}</span>
{copied ? (
<Check className="h-3 w-3 text-primary" />
) : (
<Copy className="h-3 w-3 text-slate-600" />
<Copy className="h-3 w-3 text-muted-foreground" />
)}
</button>
{contact.email && (
<div className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-300">
<Mail className="h-4 w-4 text-slate-500" />
<div className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground">
<Mail className="h-4 w-4 text-muted-foreground" />
<span className="truncate">{contact.email}</span>
</div>
)}
</div>
{/* Divider */}
<div className="my-4 border-t border-slate-800" />
<div className="my-4 border-t border-border" />
{/* Tags */}
<div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<TagIcon className="h-3 w-3" />
Tags
</div>
<div className="mt-2 flex flex-wrap gap-1">
{tags.length === 0 ? (
<p className="px-1 text-xs text-slate-600">No tags</p>
<p className="px-1 text-xs text-muted-foreground">No tags</p>
) : (
tags.map((tag) => (
<span
@ -204,27 +204,27 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
</div>
{/* Divider */}
<div className="my-4 border-t border-slate-800" />
<div className="my-4 border-t border-border" />
{/* Active Deals */}
<div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<DollarSign className="h-3 w-3" />
Active Deals
</div>
<div className="mt-2 space-y-2">
{deals.length === 0 ? (
<p className="px-1 text-xs text-slate-600">No deals</p>
<p className="px-1 text-xs text-muted-foreground">No deals</p>
) : (
deals.map((deal) => (
<div
key={deal.id}
className="rounded-lg bg-slate-800 px-3 py-2"
className="rounded-lg bg-muted px-3 py-2"
>
<p className="text-sm font-medium text-white">
<p className="text-sm font-medium text-foreground">
{deal.title}
</p>
<div className="mt-1 flex items-center justify-between text-xs text-slate-400">
<div className="mt-1 flex items-center justify-between text-xs text-muted-foreground">
<span>
{deal.currency ?? "$"}
{deal.value.toLocaleString()}
@ -248,11 +248,11 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
</div>
{/* Divider */}
<div className="my-4 border-t border-slate-800" />
<div className="my-4 border-t border-border" />
{/* Notes */}
<div>
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
<StickyNote className="h-3 w-3" />
Notes
</div>
@ -263,7 +263,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
onChange={(e) => setNewNote(e.target.value)}
placeholder="Add a note..."
rows={2}
className="flex-1 resize-none rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-xs text-white placeholder-slate-500 outline-none focus:border-primary/50"
className="flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-xs text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
/>
<Button
size="sm"
@ -279,12 +279,12 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
{notes.map((note) => (
<div
key={note.id}
className="rounded-lg bg-slate-800 px-3 py-2"
className="rounded-lg bg-muted px-3 py-2"
>
<p className="whitespace-pre-wrap text-xs text-slate-300">
<p className="whitespace-pre-wrap text-xs text-muted-foreground">
{note.note_text}
</p>
<p className="mt-1 text-[10px] text-slate-600">
<p className="mt-1 text-[10px] text-muted-foreground">
{format(new Date(note.created_at), "MMM d, yyyy HH:mm")}
</p>
</div>

View File

@ -33,11 +33,14 @@ interface ConversationListProps {
const STATUS_COLORS: Record<ConversationStatus, string> = {
open: "bg-primary",
pending: "bg-amber-500",
closed: "bg-slate-500",
closed: "bg-muted-foreground",
};
const FILTER_OPTIONS: { label: string; value: ConversationStatus | "all" }[] = [
type InboxFilter = ConversationStatus | "all" | "unread";
const FILTER_OPTIONS: { label: string; value: InboxFilter }[] = [
{ label: "All", value: "all" },
{ label: "Unread", value: "unread" },
{ label: "Open", value: "open" },
{ label: "Pending", value: "pending" },
{ label: "Closed", value: "closed" },
@ -51,7 +54,7 @@ export function ConversationList({
resyncToken = 0,
}: ConversationListProps) {
const [search, setSearch] = useState("");
const [filter, setFilter] = useState<ConversationStatus | "all">("all");
const [filter, setFilter] = useState<InboxFilter>("all");
const [loading, setLoading] = useState(true);
// Keep the latest callback in a ref so the fetch effect below can
@ -110,7 +113,9 @@ export function ConversationList({
const filtered = useMemo(() => {
let result = conversations;
if (filter !== "all") {
if (filter === "unread") {
result = result.filter((c) => c.unread_count > 0);
} else if (filter !== "all") {
result = result.filter((c) => c.status === filter);
}
@ -147,27 +152,27 @@ export function ConversationList({
// w-full on mobile so the list occupies the whole viewport when it's
// the single pane showing; fixed 320px on desktop where it shares the
// row with the thread + contact sidebar.
<div className="flex h-full w-full flex-col border-r border-slate-800 bg-slate-900 lg:w-80">
<div className="flex h-full w-full flex-col border-r border-border bg-card lg:w-80">
{/* Search + Filter */}
<div className="space-y-2 border-b border-slate-800 p-3">
<div className="space-y-2 border-b border-border p-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={handleSearchChange}
placeholder="Search conversations..."
className="border-slate-700 bg-slate-800 pl-9 text-sm text-white placeholder-slate-500 focus:border-primary/50"
className="border-border bg-muted pl-9 text-sm text-foreground placeholder-muted-foreground focus:border-primary/50"
/>
</div>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-slate-400 hover:text-white rounded-md hover:bg-slate-800">
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground rounded-md hover:bg-muted">
{activeFilter?.label ?? "All"}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="border-slate-700 bg-slate-800"
className="border-border bg-popover"
>
{FILTER_OPTIONS.map((opt) => (
<DropdownMenuItem
@ -177,7 +182,7 @@ export function ConversationList({
"text-sm",
filter === opt.value
? "text-primary"
: "text-slate-300"
: "text-popover-foreground"
)}
>
{opt.label}
@ -200,7 +205,7 @@ export function ConversationList({
</div>
) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center">
<p className="text-sm text-slate-500">No conversations found</p>
<p className="text-sm text-muted-foreground">No conversations found</p>
</div>
) : (
<div className="flex flex-col">
@ -248,12 +253,12 @@ function ConversationItem({
<button
onClick={handleClick}
className={cn(
"flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-slate-800/50",
isActive && "border-l-2 border-primary bg-slate-800/70"
"flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-muted/50",
isActive && "border-l-2 border-primary bg-muted/70"
)}
>
{/* Avatar */}
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium text-foreground">
{contact?.avatar_url ? (
<img
src={contact.avatar_url}
@ -268,13 +273,13 @@ function ConversationItem({
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="truncate text-sm font-medium text-white">
<span className="truncate text-sm font-medium text-foreground">
{displayName}
</span>
<span className="shrink-0 text-[10px] text-slate-500">{timeAgo}</span>
<span className="shrink-0 text-[10px] text-muted-foreground">{timeAgo}</span>
</div>
<div className="mt-0.5 flex items-center justify-between gap-2">
<p className="truncate text-xs text-slate-400">
<p className="truncate text-xs text-muted-foreground">
{conversation.last_message_text || "No messages yet"}
</p>
<div className="flex shrink-0 items-center gap-1.5">

View File

@ -95,7 +95,7 @@ export function MessageActions({
<div
data-touch-open={touchOpen || pickerOpen ? "true" : undefined}
className={cn(
"absolute -top-3 z-10 flex h-7 items-center gap-0.5 rounded-full border border-slate-700 bg-slate-900/95 px-1 shadow-md backdrop-blur-sm transition-opacity",
"absolute -top-3 z-10 flex h-7 items-center gap-0.5 rounded-full border border-border bg-popover/95 px-1 shadow-md backdrop-blur-sm transition-opacity",
"opacity-0 group-hover/actions:opacity-100 group-focus-within/actions:opacity-100",
"data-[touch-open=true]:opacity-100",
isAgent ? "right-3" : "left-3",
@ -103,7 +103,7 @@ export function MessageActions({
>
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="React"
>
<SmilePlus className="h-3.5 w-3.5" />
@ -117,7 +117,7 @@ export function MessageActions({
key={e}
type="button"
onClick={() => handlePickEmoji(e)}
className="flex h-8 w-8 items-center justify-center rounded-full text-lg leading-none transition-transform hover:scale-125 hover:bg-slate-700"
className="flex h-8 w-8 items-center justify-center rounded-full text-lg leading-none transition-transform hover:scale-125 hover:bg-muted"
aria-label={`React with ${e}`}
>
{e}
@ -128,7 +128,7 @@ export function MessageActions({
<button
type="button"
onClick={handleReply}
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="Reply"
>
<CornerUpLeft className="h-3.5 w-3.5" />
@ -136,7 +136,7 @@ export function MessageActions({
<button
type="button"
onClick={handleCopy}
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
aria-label="Copy"
>
<Copy className="h-3.5 w-3.5" />

View File

@ -30,11 +30,11 @@ interface MessageBubbleProps {
function StatusIcon({ status }: { status: Message["status"] }) {
switch (status) {
case "sending":
return <Clock className="h-3 w-3 text-slate-400" />;
return <Clock className="h-3 w-3 text-muted-foreground" />;
case "sent":
return <Check className="h-3 w-3 text-slate-400" />;
return <Check className="h-3 w-3 text-muted-foreground" />;
case "delivered":
return <CheckCheck className="h-3 w-3 text-slate-400" />;
return <CheckCheck className="h-3 w-3 text-muted-foreground" />;
case "read":
return <CheckCheck className="h-3 w-3 text-blue-400" />;
case "failed":
@ -46,8 +46,8 @@ function StatusIcon({ status }: { status: Message["status"] }) {
function MediaUnavailable({ label }: { label: string }) {
return (
<div className="flex items-center gap-2 rounded-lg bg-slate-700/40 px-3 py-2 text-xs text-slate-300">
<ImageOff className="h-4 w-4 shrink-0 text-slate-500" />
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<ImageOff className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{label} unavailable</span>
</div>
);
@ -92,15 +92,15 @@ function MediaImage({ url, alt }: { url: string; alt: string }) {
if (error) {
return (
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-slate-700">
<ImageOff className="h-8 w-8 text-slate-500" />
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-muted">
<ImageOff className="h-8 w-8 text-muted-foreground" />
</div>
);
}
if (loading) {
return (
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-slate-700">
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-muted">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
@ -181,9 +181,9 @@ function MessageContent({ message }: { message: Message }) {
href={message.media_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 rounded-lg bg-slate-700/50 px-3 py-2 text-sm hover:bg-slate-700"
className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2 text-sm hover:bg-muted"
>
<FileText className="h-5 w-5 shrink-0 text-slate-400" />
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
<span className="truncate">
{message.content_text || "Document"}
</span>
@ -208,7 +208,7 @@ function MessageContent({ message }: { message: Message }) {
case "location":
return (
<div className="flex items-center gap-2 text-sm">
<MapPin className="h-4 w-4 shrink-0 text-slate-400" />
<MapPin className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{message.content_text || "Location shared"}</span>
</div>
);
@ -221,7 +221,7 @@ function MessageContent({ message }: { message: Message }) {
// tap rather than the customer typing the same words.
return (
<div className="flex flex-col gap-0.5">
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
<CornerDownLeft className="h-3 w-3" />
Button reply
</span>
@ -265,11 +265,15 @@ export function MessageBubble({
"relative rounded-2xl px-3 py-2",
isAgent
? "rounded-br-md bg-primary text-primary-foreground"
: "rounded-bl-md bg-slate-800 text-slate-100",
: "rounded-bl-md bg-muted text-foreground",
)}
>
{reply && (
<ReplyQuote authorLabel={reply.authorLabel} preview={reply.preview} />
<ReplyQuote
authorLabel={reply.authorLabel}
preview={reply.preview}
onPrimary={isAgent}
/>
)}
<MessageContent message={message} />
<div
@ -278,7 +282,18 @@ export function MessageBubble({
isAgent ? "justify-end" : "justify-start",
)}
>
<span className="text-[10px] text-white/60">{time}</span>
<span
className={cn(
"text-[10px]",
// Outbound bubbles sit on the primary fill, so the
// timestamp must read against that (not the neutral
// foreground) — otherwise it goes low-contrast in light
// mode. Inbound bubbles use the muted surface.
isAgent ? "text-primary-foreground/70" : "text-muted-foreground",
)}
>
{time}
</span>
{isAgent && <StatusIcon status={message.status} />}
</div>
</div>

View File

@ -1,13 +1,68 @@
"use client";
import { useState, useRef, useCallback, KeyboardEvent } from "react";
import { Send, LayoutTemplate } from "lucide-react";
import {
useState,
useRef,
useCallback,
useEffect,
KeyboardEvent,
} from "react";
import {
Send,
LayoutTemplate,
Paperclip,
Image as ImageIcon,
Video,
FileText,
Mic,
Square,
X,
Loader2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { GatedButton } from "@/components/ui/gated-button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useCan } from "@/hooks/use-can";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import {
uploadAccountMedia,
deleteAccountMedia,
MEDIA_MAX_BYTES_BY_KIND,
} from "@/lib/storage/upload-media";
import { ReplyQuote } from "./reply-quote";
/** Media content types an agent can send from the composer. */
export type ComposerMediaKind = "image" | "video" | "document" | "audio";
/** Supabase Storage bucket holding agent-sent chat attachments (migration 023). */
export const CHAT_MEDIA_BUCKET = "chat-media";
/** Meta caps media captions at 1024 chars. Enforced here and in the send route. */
export const MEDIA_CAPTION_MAX = 1024;
/** Hard cap on a single voice recording so it can't blow the upload/
* transcode limits — auto-stops the recorder when reached. */
const MAX_RECORDING_SECONDS = 5 * 60;
export interface SendMediaPayload {
kind: ComposerMediaKind;
/** Public chat-media URL Meta fetches at send time. */
mediaUrl: string;
/** Storage object path — lets the caller GC the object if the send fails. */
path: string;
/** Optional caption (image/video/document only). */
caption?: string;
/** Original file name — surfaced to the recipient for documents. */
filename?: string;
replyToId?: string;
}
interface ReplyDraft {
/** Internal UUID of the message being replied to — sent back through onSend. */
id: string;
@ -15,19 +70,52 @@ interface ReplyDraft {
preview: string;
}
// Mirrors the chat-media bucket's allowed_mime_types (migration 023) for
// the file picker so unsupported files are rejected before upload rather
// than failing with a confusing Storage error. Audio has no picker — it's
// captured via the recorder.
const PICKER_ACCEPT: Record<"image" | "video" | "document", string> = {
image: "image/png,image/jpeg,image/webp",
video: "video/mp4,video/3gpp",
document:
"application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain",
};
interface MediaDraft {
kind: ComposerMediaKind;
mediaUrl: string;
/** Storage path — used to GC the object if the draft is discarded. */
path: string;
filename: string;
caption: string;
}
interface MessageComposerProps {
conversationId: string;
sessionExpired: boolean;
onSend: (text: string, replyToId?: string) => void;
onSendMedia: (payload: SendMediaPayload) => void;
onOpenTemplates: () => void;
replyTo?: ReplyDraft | null;
onClearReply?: () => void;
}
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${s.toString().padStart(2, "0")}`;
}
/** Worker that encodes mic input to Ogg/Opus entirely in the browser
* (vendored from opus-recorder into /public). Recording client-side in a
* Meta-accepted format means no server ffmpeg / transcode step. */
const OPUS_ENCODER_PATH = "/opus/encoderWorker.min.js";
export function MessageComposer({
conversationId,
sessionExpired,
onSend,
onSendMedia,
onOpenTemplates,
replyTo,
onClearReply,
@ -35,11 +123,63 @@ export function MessageComposer({
const [text, setText] = useState("");
const [sending, setSending] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Media attachment state. `draft` holds an uploaded-but-not-yet-sent
// attachment; `busy` covers the upload/transcode window.
const [draft, setDraft] = useState<MediaDraft | null>(null);
const [busy, setBusy] = useState(false);
const imageInputRef = useRef<HTMLInputElement>(null);
const videoInputRef = useRef<HTMLInputElement>(null);
const documentInputRef = useRef<HTMLInputElement>(null);
// Mirror of `draft` for the unmount cleanup, which can't read render
// state. Kept in sync below so navigating away with a staged-but-unsent
// attachment GCs the orphaned object.
const draftRef = useRef<MediaDraft | null>(null);
useEffect(() => {
draftRef.current = draft;
}, [draft]);
// Best-effort GC of a staged object the user never sent. Fire-and-forget.
const removeStaged = useCallback((path: string | undefined) => {
if (!path) return;
void deleteAccountMedia(CHAT_MEDIA_BUCKET, path).catch(() => {});
}, []);
// Voice recording state. The recorder encodes Ogg/Opus in-browser
// (opus-recorder) so there's no server-side transcode.
const [recording, setRecording] = useState(false);
const [recordSeconds, setRecordSeconds] = useState(0);
const recorderRef = useRef<import("opus-recorder").default | null>(null);
const cancelledRef = useRef(false);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Viewers (read-only role) can browse the inbox but never send.
// For solo users this is always true — single-owner accounts pass
// every capability — so the disabled branch is a no-op there.
const canSend = useCan("send-messages");
const readOnly = !canSend;
// Media (like free-form text) is only allowed inside the 24h window.
const inputsDisabled = readOnly || sessionExpired;
const clearTimer = useCallback(() => {
if (timerRef.current !== null) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
// Tear down any live recording + timer on unmount so a mid-record
// navigation doesn't leak the mic, and GC a staged-but-unsent
// attachment so it doesn't orphan in the bucket.
useEffect(() => {
return () => {
clearTimer();
cancelledRef.current = true;
// stop() releases the mic stream + audio context inside opus-recorder.
void recorderRef.current?.stop().catch(() => {});
removeStaged(draftRef.current?.path);
};
}, [clearTimer, removeStaged]);
const adjustHeight = useCallback(() => {
const el = textareaRef.current;
@ -83,8 +223,162 @@ export function MessageComposer({
[adjustHeight]
);
// Upload a captured file to chat-media and stage it as a draft.
const stageUpload = useCallback(
async (kind: ComposerMediaKind, file: File) => {
// Per-kind ceiling mirrors Meta's caps (image 5 MB, etc.) so we
// reject before upload rather than orphaning an object that Meta
// would then refuse at send.
const max = MEDIA_MAX_BYTES_BY_KIND[kind];
if (file.size > max) {
toast.error(
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — ${kind} limit is ${Math.round(
max / 1024 / 1024,
)} MB.`,
);
return;
}
setBusy(true);
try {
const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
// Replacing an existing draft? GC the previous object first.
removeStaged(draftRef.current?.path);
setDraft({ kind, mediaUrl: publicUrl, path, filename: file.name, caption: "" });
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed.");
} finally {
setBusy(false);
}
},
[removeStaged],
);
const handlePicked = useCallback(
(kind: "image" | "video" | "document", file: File | undefined) => {
if (file) void stageUpload(kind, file);
},
[stageUpload],
);
// ---- Voice recording (client-side Ogg/Opus, no server transcode) ---
// The encoded Ogg/Opus file from opus-recorder → upload as an audio
// draft. WhatsApp renders Ogg/Opus as a playable voice note.
const finalizeRecording = useCallback(
async (bytes: Uint8Array) => {
// Uint8Array is a valid BlobPart at runtime; the cast sidesteps the
// lib.dom ArrayBufferLike-vs-ArrayBuffer generic mismatch.
const file = new File([bytes as unknown as BlobPart], `voice-${Date.now()}.ogg`, {
type: "audio/ogg",
});
if (file.size === 0) return; // cancelled / empty take
if (file.size > MEDIA_MAX_BYTES_BY_KIND.audio) {
toast.error("Recording is too long (over 16 MB).");
return;
}
setBusy(true);
try {
const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
removeStaged(draftRef.current?.path);
setDraft({ kind: "audio", mediaUrl: publicUrl, path, filename: file.name, caption: "" });
} catch (err) {
toast.error(err instanceof Error ? err.message : "Upload failed.");
} finally {
setBusy(false);
}
},
[removeStaged],
);
const startRecording = useCallback(async () => {
if (inputsDisabled || busy || recording) return;
if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") {
toast.error("Voice recording isn't supported in this browser.");
return;
}
try {
// Lazy-load the encoder (≈400 KB worker) only when the user records,
// keeping it out of the main bundle.
const { default: Recorder } = await import("opus-recorder");
const recorder = new Recorder({
encoderPath: OPUS_ENCODER_PATH,
numberOfChannels: 1,
encoderApplication: 2048, // VOIP — tuned for speech
encoderSampleRate: 48000,
streamPages: false, // one callback with the complete file on stop
});
cancelledRef.current = false;
recorder.ondataavailable = (bytes) => {
if (cancelledRef.current) return;
void finalizeRecording(bytes);
};
recorderRef.current = recorder;
await recorder.start();
setRecording(true);
setRecordSeconds(0);
timerRef.current = setInterval(() => setRecordSeconds((s) => s + 1), 1000);
} catch {
void recorderRef.current?.stop().catch(() => {});
recorderRef.current = null;
toast.error("Microphone access denied or unavailable.");
}
}, [inputsDisabled, busy, recording, finalizeRecording]);
const stopRecording = useCallback(() => {
clearTimer();
setRecording(false);
void recorderRef.current?.stop().catch(() => {});
}, [clearTimer]);
const cancelRecording = useCallback(() => {
cancelledRef.current = true;
clearTimer();
setRecording(false);
void recorderRef.current?.stop().catch(() => {});
}, [clearTimer]);
// Auto-stop at the cap so a forgotten recording can't blow the
// upload size limit.
useEffect(() => {
if (recording && recordSeconds >= MAX_RECORDING_SECONDS) {
stopRecording();
}
}, [recording, recordSeconds, stopRecording]);
// ---- Draft send / discard -----------------------------------------
const sendDraft = useCallback(() => {
if (!draft || busy) return;
onSendMedia({
kind: draft.kind,
mediaUrl: draft.mediaUrl,
path: draft.path,
// Audio takes no caption (Meta rejects it). Everything else: the
// trimmed caption, or undefined when blank.
caption:
draft.kind === "audio" ? undefined : draft.caption.trim() || undefined,
filename: draft.kind === "document" ? draft.filename : undefined,
replyToId: replyTo?.id,
});
// The object is now owned by the sent message — clear without GC.
setDraft(null);
onClearReply?.();
}, [draft, busy, onSendMedia, replyTo?.id, onClearReply]);
// Discard GCs the staged object — it was uploaded but never sent.
const discardDraft = useCallback(() => {
removeStaged(draft?.path);
setDraft(null);
}, [draft?.path, removeStaged]);
const setCaption = useCallback((caption: string) => {
setDraft((d) => (d ? { ...d, caption } : d));
}, []);
// ---- Render --------------------------------------------------------
return (
<div className="border-t border-slate-800 bg-slate-900 p-3">
<div className="border-t border-border bg-card p-3">
{replyTo && (
<div className="mb-2">
<ReplyQuote
@ -111,14 +405,119 @@ export function MessageComposer({
</div>
)}
{/* Hidden file inputs driven by the attach menu. */}
<input
ref={imageInputRef}
type="file"
accept={PICKER_ACCEPT.image}
className="hidden"
onChange={(e) => {
handlePicked("image", e.target.files?.[0]);
e.target.value = "";
}}
/>
<input
ref={videoInputRef}
type="file"
accept={PICKER_ACCEPT.video}
className="hidden"
onChange={(e) => {
handlePicked("video", e.target.files?.[0]);
e.target.value = "";
}}
/>
<input
ref={documentInputRef}
type="file"
accept={PICKER_ACCEPT.document}
className="hidden"
onChange={(e) => {
handlePicked("document", e.target.files?.[0]);
e.target.value = "";
}}
/>
{draft ? (
<MediaDraftPreview
draft={draft}
busy={busy}
readOnly={readOnly}
onCaptionChange={setCaption}
onDiscard={discardDraft}
onSend={sendDraft}
/>
) : recording ? (
// Recording bar — replaces the composer while the mic is live.
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted px-4 py-2.5">
<span className="flex h-2.5 w-2.5 shrink-0 animate-pulse rounded-full bg-red-500" />
<span className="flex-1 text-sm text-foreground">
Recording… {formatDuration(recordSeconds)} /{" "}
{formatDuration(MAX_RECORDING_SECONDS)}
</span>
<button
type="button"
onClick={cancelRecording}
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-card hover:text-foreground"
>
Cancel
</button>
<Button
size="sm"
onClick={stopRecording}
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90"
title="Stop and attach"
>
<Square className="h-4 w-4" />
</Button>
</div>
) : (
<div className="flex items-end gap-2">
{/* Attach menu — photo / video / document / voice. */}
<DropdownMenu>
<DropdownMenuTrigger
disabled={inputsDisabled || busy}
title={
readOnly
? "Read-only — your role can't send messages"
: inputsDisabled
? undefined
: "Attach media"
}
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
>
{busy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Paperclip className="h-4 w-4" />
)}
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="border-border bg-popover">
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
<ImageIcon className="mr-2 h-4 w-4" />
Photo
</DropdownMenuItem>
<DropdownMenuItem onClick={() => videoInputRef.current?.click()}>
<Video className="mr-2 h-4 w-4" />
Video
</DropdownMenuItem>
<DropdownMenuItem onClick={() => documentInputRef.current?.click()}>
<FileText className="mr-2 h-4 w-4" />
Document
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void startRecording()}>
<Mic className="mr-2 h-4 w-4" />
Voice note
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<GatedButton
variant="ghost"
size="sm"
canAct={!readOnly}
gateReason="send messages"
title={readOnly ? undefined : "Send template"}
className="h-9 w-9 shrink-0 p-0 text-slate-400 hover:text-white"
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
onClick={onOpenTemplates}
>
<LayoutTemplate className="h-4 w-4" />
@ -143,7 +542,7 @@ export function MessageComposer({
// The placeholder text also surfaces the read-only state.
title={readOnly ? "Read-only — your role can't send messages" : undefined}
className={cn(
"flex-1 resize-none rounded-xl border border-slate-700 bg-slate-800 px-4 py-2.5 text-sm text-white placeholder-slate-500 outline-none transition-colors focus:border-primary/50",
"flex-1 resize-none rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50",
(sessionExpired || readOnly) && "cursor-not-allowed opacity-50"
)}
/>
@ -159,13 +558,106 @@ export function MessageComposer({
<Send className="h-4 w-4" />
</GatedButton>
</div>
)}
{/* Hint sits outside the flex row so its height doesn't push
`items-end` buttons below the textarea. Indented to line up
under the textarea left edge (w-9 button + gap-2 = 44px). */}
<p className="mt-1 pl-11 text-[10px] text-slate-600">
under the textarea left edge. */}
{!draft && !recording && (
<p className="mt-1 pl-[5.5rem] text-[10px] text-muted-foreground">
Type &apos;/&apos; for quick replies
</p>
)}
</div>
);
}
/**
* Staged-attachment preview with caption + send/discard. Declared at
* module scope (not nested in MessageComposer) so React keeps it mounted
* across the parent's re-renders — a nested component would remount the
* caption input on every keystroke and drop focus.
*/
function MediaDraftPreview({
draft,
busy,
readOnly,
onCaptionChange,
onDiscard,
onSend,
}: {
draft: MediaDraft;
busy: boolean;
readOnly: boolean;
onCaptionChange: (caption: string) => void;
onDiscard: () => void;
onSend: () => void;
}) {
return (
<div className="rounded-xl border border-border bg-muted/40 p-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
{draft.kind === "image" && (
// eslint-disable-next-line @next/next/no-img-element
<img
src={draft.mediaUrl}
alt={draft.filename}
className="max-h-40 rounded-lg object-cover"
/>
)}
{draft.kind === "video" && (
<video src={draft.mediaUrl} controls className="max-h-40 rounded-lg" />
)}
{draft.kind === "audio" && (
<audio src={draft.mediaUrl} controls className="w-full" />
)}
{draft.kind === "document" && (
<div className="flex items-center gap-2 text-sm text-foreground">
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
<span className="truncate">{draft.filename}</span>
</div>
)}
</div>
<button
type="button"
onClick={onDiscard}
aria-label="Remove attachment"
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="mt-2 flex items-end gap-2">
{draft.kind !== "audio" && (
<input
value={draft.caption}
maxLength={MEDIA_CAPTION_MAX}
onChange={(e) => onCaptionChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
}}
placeholder="Add a caption…"
className="flex-1 rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50"
/>
)}
<GatedButton
size="sm"
canAct={!readOnly}
gateReason="send messages"
disabled={busy}
onClick={onSend}
className={cn(
"h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40",
draft.kind === "audio" && "ml-auto",
)}
>
<Send className="h-4 w-4" />
</GatedButton>
</div>
</div>
);
}

View File

@ -63,7 +63,7 @@ export function MessageReactions({
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[11px] leading-none transition-colors",
g.byCurrentUser
? "border-primary/60 bg-primary/15 text-primary hover:bg-primary/25"
: "border-slate-700 bg-slate-800/80 text-slate-200 hover:bg-slate-700",
: "border-border bg-muted/80 text-foreground hover:bg-muted",
)}
>
<span className="text-sm leading-none">{g.emoji}</span>

View File

@ -3,6 +3,9 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { usePresence } from "@/hooks/use-presence";
import { PresenceDot } from "@/components/presence/presence-dot";
import { presenceLabel } from "@/lib/presence";
import { cn } from "@/lib/utils";
import type {
Conversation,
@ -21,6 +24,8 @@ import {
Clock,
ArrowLeft,
RefreshCw,
PanelRightOpen,
PanelRightClose,
} from "lucide-react";
import { format, isToday, isYesterday, differenceInHours } from "date-fns";
import { Badge } from "@/components/ui/badge";
@ -34,7 +39,12 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area";
import { MessageBubble } from "./message-bubble";
import { MessageActions } from "./message-actions";
import { MessageComposer } from "./message-composer";
import {
MessageComposer,
CHAT_MEDIA_BUCKET,
type SendMediaPayload,
} from "./message-composer";
import { deleteAccountMedia } from "@/lib/storage/upload-media";
import { TemplatePicker } from "./template-picker";
import { buildReplyPreview } from "./reply-quote";
import { toast } from "sonner";
@ -87,6 +97,15 @@ interface MessageThreadProps {
* working; the button is only rendered when this is provided.
*/
onRefresh?: () => void;
/**
* Desktop-only contact-panel toggle. The page owns the open/closed
* state (it's the one that renders the sidebar), so the thread just
* reflects it and asks the page to flip it. Both optional so existing
* callers keep working; the toggle button only renders when
* `onToggleContactPanel` is wired up.
*/
contactPanelOpen?: boolean;
onToggleContactPanel?: () => void;
}
function formatDateSeparator(dateStr: string): string {
@ -116,7 +135,7 @@ function groupMessagesByDate(messages: Message[]) {
const STATUS_OPTIONS: { label: string; value: ConversationStatus; color: string }[] = [
{ label: "Open", value: "open", color: "text-primary" },
{ label: "Pending", value: "pending", color: "text-amber-400" },
{ label: "Closed", value: "closed", color: "text-slate-400" },
{ label: "Closed", value: "closed", color: "text-muted-foreground" },
];
/**
@ -129,7 +148,7 @@ const STATUS_OPTIONS: { label: string; value: ConversationStatus; color: string
* if we ever switch the asset, both spots update together.
*/
const DOODLE_BG_CLASSES =
"bg-slate-950 bg-[url('/inbox-doodle.svg')] bg-repeat";
"bg-background bg-[url('/inbox-doodle.svg')] bg-repeat";
export function MessageThread({
conversation,
@ -143,8 +162,11 @@ export function MessageThread({
onBack,
resyncToken = 0,
onRefresh,
contactPanelOpen,
onToggleContactPanel,
}: MessageThreadProps) {
const { user } = useAuth();
const { getPresence, getRow, now } = usePresence();
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
@ -473,6 +495,72 @@ export function MessageThread({
[conversation, onNewMessage, onUpdateMessage]
);
const handleSendMedia = useCallback(
async (payload: SendMediaPayload) => {
if (!conversation) return;
// Documents show their filename in our own bubble (and to the
// recipient as the Meta caption when no caption was typed); other
// kinds use the caption as-is. Audio carries no caption.
const contentText =
payload.kind === "document"
? payload.caption || payload.filename || "Document"
: payload.caption;
const tempId = `temp-${Date.now()}`;
const optimisticMsg: Message = {
id: tempId,
conversation_id: conversation.id,
sender_type: "agent",
content_type: payload.kind,
content_text: contentText,
media_url: payload.mediaUrl,
status: "sending",
created_at: new Date().toISOString(),
reply_to_message_id: payload.replyToId,
};
onNewMessage(optimisticMsg);
setReplyTo(null);
try {
const res = await fetch("/api/whatsapp/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
conversation_id: conversation.id,
message_type: payload.kind,
media_url: payload.mediaUrl,
content_text: contentText,
filename: payload.filename,
reply_to_message_id: payload.replyToId,
}),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const reason = data?.error || `HTTP ${res.status}`;
console.error("Failed to send media:", reason);
toast.error(`Failed to send: ${reason}`);
onUpdateMessage(tempId, { status: "failed" });
// The upload never reached the recipient — GC the orphaned
// object rather than leaving it in the public bucket forever.
void deleteAccountMedia(CHAT_MEDIA_BUCKET, payload.path).catch(() => {});
return;
}
onUpdateMessage(tempId, { status: "sent" });
} catch (err) {
console.error("Failed to send media:", err);
const reason = err instanceof Error ? err.message : "network error";
toast.error(`Failed to send: ${reason}`);
onUpdateMessage(tempId, { status: "failed" });
void deleteAccountMedia(CHAT_MEDIA_BUCKET, payload.path).catch(() => {});
}
},
[conversation, onNewMessage, onUpdateMessage],
);
const handleStatusChange = useCallback(
async (status: ConversationStatus) => {
if (!conversation) return;
@ -696,13 +784,13 @@ export function MessageThread({
if (!conversation || !contact) {
return (
<div className={cn("flex flex-1 flex-col items-center justify-center", DOODLE_BG_CLASSES)}>
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-800">
<MessageSquare className="h-8 w-8 text-slate-600" />
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<MessageSquare className="h-8 w-8 text-muted-foreground" />
</div>
<h3 className="mt-4 text-sm font-medium text-slate-400">
<h3 className="mt-4 text-sm font-medium text-muted-foreground">
Select a conversation
</h3>
<p className="mt-1 text-xs text-slate-600">
<p className="mt-1 text-xs text-muted-foreground">
Choose a conversation from the left to start messaging
</p>
</div>
@ -721,10 +809,18 @@ export function MessageThread({
: "Assign";
return (
<div className={cn("flex flex-1 flex-col", DOODLE_BG_CLASSES)}>
{/* Header — solid bg-slate-900 sits on top of the doodle so the
// `min-w-0` is load-bearing: the page already puts min-w-0 on the
// thread's flex *wrapper* (issue #165), but this root keeps the
// default `min-width: auto`, so a single wide message (long unbroken
// URL/word) expands the whole thread past its flex share and the chat
// paints on top of the contact sidebar at lg+ — outgoing bubbles get
// clipped and the hover toolbar overlaps the Tags panel. Letting the
// root shrink lets the bubbles' break-words / max-w caps apply.
// Issue #257.
<div className={cn("flex min-w-0 flex-1 flex-col", DOODLE_BG_CLASSES)}>
{/* Header — solid card surface sits on top of the doodle so the
name/avatar/dropdowns stay legible. */}
<div className="flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-900 px-3 py-3 sm:px-4">
<div className="flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-3 sm:px-4">
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
{/* Back-to-list button — mobile only. Hidden on lg+ where the
conversation list is always visible next to the thread. */}
@ -733,24 +829,24 @@ export function MessageThread({
type="button"
onClick={onBack}
aria-label="Back to conversations"
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-slate-300 hover:bg-slate-800 hover:text-white lg:hidden"
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground lg:hidden"
>
<ArrowLeft className="h-5 w-5" />
</button>
)}
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium text-foreground">
{displayName.charAt(0).toUpperCase()}
</div>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-white">{displayName}</h2>
<p className="truncate text-xs text-slate-400">{contact.phone}</p>
<h2 className="truncate text-sm font-semibold text-foreground">{displayName}</h2>
<p className="truncate text-xs text-muted-foreground">{contact.phone}</p>
</div>
{/* Session timer badge — hidden on the narrowest phones so
the name + back arrow keep their room. */}
<Badge
variant="outline"
className={cn(
"ml-1 hidden gap-1 border-slate-700 text-[10px] sm:inline-flex sm:ml-2",
"ml-1 hidden gap-1 border-border text-[10px] sm:inline-flex sm:ml-2",
sessionInfo.expired ? "text-red-400" : "text-primary"
)}
>
@ -760,6 +856,33 @@ export function MessageThread({
</div>
<div className="flex items-center gap-2">
{/* Contact-panel toggle — desktop only. The contact sidebar
eats a chunk of horizontal width that crowds the thread on
smaller laptops; this lets agents reclaim it when they just
want to read and reply. Hidden on mobile, where the sidebar
never renders as a permanent panel anyway. Issue #258. */}
{onToggleContactPanel && (
<button
type="button"
onClick={onToggleContactPanel}
aria-label={
contactPanelOpen ? "Hide contact panel" : "Show contact panel"
}
aria-pressed={contactPanelOpen}
title={contactPanelOpen ? "Hide contact" : "Show contact"}
className={cn(
"hidden h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground lg:inline-flex",
contactPanelOpen ? "text-primary" : "text-muted-foreground",
)}
>
{contactPanelOpen ? (
<PanelRightClose className="h-4 w-4" />
) : (
<PanelRightOpen className="h-4 w-4" />
)}
</button>
)}
{/* Manual refresh — forces a refetch of the messages + the
conversation list (the parent bumps its resyncToken). Useful
when realtime missed an event or the agent just wants to be
@ -773,7 +896,7 @@ export function MessageThread({
aria-label="Refresh conversation"
title="Refresh"
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white disabled:opacity-60",
"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-60",
)}
>
<RefreshCw
@ -785,15 +908,15 @@ export function MessageThread({
{/* Status dropdown */}
<DropdownMenu>
<DropdownMenuTrigger className={cn(
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-slate-800",
currentStatus?.color ?? "text-slate-400"
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
currentStatus?.color ?? "text-muted-foreground"
)}>
{currentStatus?.label ?? "Status"}
<ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="border-slate-700 bg-slate-800"
className="border-border bg-popover"
>
{STATUS_OPTIONS.map((opt) => (
<DropdownMenuItem
@ -811,8 +934,8 @@ export function MessageThread({
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-slate-800",
assignedAgentId ? "text-primary" : "text-slate-400"
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
assignedAgentId ? "text-primary" : "text-muted-foreground"
)}
>
<UserPlus className="h-3 w-3" />
@ -821,24 +944,34 @@ export function MessageThread({
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="border-slate-700 bg-slate-800"
className="border-border bg-popover"
>
{profiles.length === 0 ? (
<DropdownMenuItem disabled className="text-sm text-slate-500">
<DropdownMenuItem disabled className="text-sm text-muted-foreground">
No teammates available
</DropdownMenuItem>
) : (
profiles.map((p) => {
const isSelected = p.user_id === assignedAgentId;
const presence = getPresence(p.user_id);
return (
<DropdownMenuItem
key={p.id}
onClick={() => handleAssignChange(p.user_id)}
className={cn(
"text-sm",
isSelected ? "text-primary" : "text-slate-300"
isSelected ? "text-primary" : "text-popover-foreground"
)}
>
<PresenceDot
status={presence}
label={presenceLabel(
presence,
getRow(p.user_id)?.last_seen_at ?? null,
now
)}
className="mr-2"
/>
<span className="flex-1">
{p.full_name}
{p.user_id === user?.id ? " (me)" : ""}
@ -850,10 +983,10 @@ export function MessageThread({
)}
{assignedAgentId && (
<>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
onClick={() => handleAssignChange(null)}
className="text-sm text-slate-400"
className="text-sm text-muted-foreground"
>
Unassign
</DropdownMenuItem>
@ -872,8 +1005,8 @@ export function MessageThread({
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12">
<p className="text-sm text-slate-500">No messages yet</p>
<p className="text-xs text-slate-600">
<p className="text-sm text-muted-foreground">No messages yet</p>
<p className="text-xs text-muted-foreground">
Send a template to start the conversation
</p>
</div>
@ -883,7 +1016,7 @@ export function MessageThread({
<div key={group.date}>
{/* Date separator */}
<div className="mb-4 flex items-center justify-center">
<span className="rounded-full bg-slate-800 px-3 py-1 text-[10px] font-medium text-slate-400">
<span className="rounded-full bg-muted px-3 py-1 text-[10px] font-medium text-muted-foreground">
{formatDateSeparator(group.date)}
</span>
</div>
@ -942,6 +1075,7 @@ export function MessageThread({
conversationId={conversation.id}
sessionExpired={sessionInfo.expired}
onSend={handleSend}
onSendMedia={handleSendMedia}
onOpenTemplates={handleOpenTemplates}
replyTo={replyTo}
onClearReply={() => setReplyTo(null)}

View File

@ -14,25 +14,38 @@ interface ReplyQuoteProps {
/** Present → renders the composer-chip variant with an X button. Absent →
* renders the embedded-in-bubble variant. */
onDismiss?: () => void;
/** True when embedded inside an outbound (primary-filled) bubble, so the
* quote must read against the primary surface rather than the neutral
* foreground — otherwise it goes low-contrast in light mode. */
onPrimary?: boolean;
}
export function ReplyQuote({
authorLabel,
preview,
onDismiss,
onPrimary = false,
}: ReplyQuoteProps) {
const isChip = !!onDismiss;
return (
<div
className={cn(
"flex items-start gap-2 border-l-2 border-primary px-2 py-1",
"flex items-start gap-2 border-l-2 px-2 py-1",
onPrimary ? "border-primary-foreground/50" : "border-primary",
isChip
? "rounded-md bg-slate-800/80"
: "mb-1.5 rounded-md bg-black/20",
? "rounded-md bg-muted/80"
: onPrimary
? "mb-1.5 rounded-md bg-primary-foreground/15"
: "mb-1.5 rounded-md bg-background/20",
)}
>
<div className="min-w-0 flex-1 overflow-hidden">
<div className="truncate text-[11px] font-medium text-primary">
<div
className={cn(
"truncate text-[11px] font-medium",
onPrimary ? "text-primary-foreground" : "text-primary",
)}
>
{authorLabel}
</div>
{/* Wrap the preview instead of truncating to a single line.
@ -42,7 +55,7 @@ export function ReplyQuote({
* layout wider, shoving the contact sidebar off-screen.
* `break-words` also wraps long URLs that have no whitespace
* to break on. Issue #165. */}
<div className="whitespace-pre-wrap break-words text-xs text-slate-200/80">
<div className="whitespace-pre-wrap break-words text-xs text-foreground/80">
{preview}
</div>
</div>
@ -51,7 +64,7 @@ export function ReplyQuote({
type="button"
onClick={onDismiss}
aria-label="Cancel reply"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 hover:bg-slate-700 hover:text-white"
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>

View File

@ -104,10 +104,13 @@ export function TemplatePicker({
return;
}
// Scope by RLS (message_templates_select → is_account_member), NOT by
// user_id. Templates are account-owned, so filtering on the caller's
// user_id hid templates that a teammate created — leaving them unable
// to send approved templates in a shared account.
const { data, error } = await supabase
.from("message_templates")
.select("*")
.eq("user_id", user.id)
.eq("status", "APPROVED")
.order("created_at", { ascending: false });
@ -183,13 +186,13 @@ export function TemplatePicker({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="border-slate-700 bg-slate-900 sm:max-w-lg">
<DialogContent className="border-border bg-popover sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<LayoutTemplate className="h-4 w-4 text-primary" />
{selected ? selected.name : "Send template"}
</DialogTitle>
<DialogDescription className="text-slate-400">
<DialogDescription className="text-muted-foreground">
{selected
? "Fill in the placeholders to render this template. Meta requires every variable to be set."
: "Pick an approved WhatsApp template to send to this contact."}
@ -203,9 +206,9 @@ export function TemplatePicker({
<Loader2 className="h-5 w-5 animate-spin text-primary" />
</div>
) : templates.length === 0 ? (
<div className="rounded-md border border-slate-800 bg-slate-950/50 p-6 text-center">
<p className="text-sm text-slate-300">No approved templates</p>
<p className="mt-1 text-xs text-slate-500">
<div className="rounded-md border border-border bg-background/50 p-6 text-center">
<p className="text-sm text-popover-foreground">No approved templates</p>
<p className="mt-1 text-xs text-muted-foreground">
Approve a template in Meta WhatsApp Manager, then sync it
from Settings → Templates.
</p>
@ -216,28 +219,28 @@ export function TemplatePicker({
key={t.id}
type="button"
onClick={() => pickTemplate(t)}
className="w-full rounded-md border border-slate-800 bg-slate-950/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-slate-900"
className="w-full rounded-md border border-border bg-background/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-popover"
>
<div className="flex items-start gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="truncate text-sm font-medium text-white">
<p className="truncate text-sm font-medium text-popover-foreground">
{t.name}
</p>
<Badge className="border border-primary/30 bg-primary/20 text-[10px] text-primary">
{t.category}
</Badge>
{t.language && (
<span className="text-[10px] uppercase text-slate-500">
<span className="text-[10px] uppercase text-muted-foreground">
{t.language}
</span>
)}
</div>
<p className="mt-1 line-clamp-2 text-xs text-slate-400">
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{t.body_text}
</p>
</div>
<ChevronRight className="h-4 w-4 flex-shrink-0 text-slate-500" />
<ChevronRight className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
</div>
</button>
))
@ -245,33 +248,33 @@ export function TemplatePicker({
</div>
) : (
<div className="space-y-3">
<div className="rounded-md border border-slate-800 bg-slate-950/50 p-3">
<p className="mb-1 text-xs text-slate-400">Preview</p>
<p className="whitespace-pre-wrap text-sm text-slate-200">
<div className="rounded-md border border-border bg-background/50 p-3">
<p className="mb-1 text-xs text-muted-foreground">Preview</p>
<p className="whitespace-pre-wrap text-sm text-popover-foreground">
{renderBodyPreview(selected.body_text, params)}
</p>
{selected.footer_text && (
<p className="mt-2 text-xs italic text-slate-500">
<p className="mt-2 text-xs italic text-muted-foreground">
{selected.footer_text}
</p>
)}
</div>
{slots && slots.headerVarCount > 0 && (
<div className="space-y-1">
<Label className="text-xs text-slate-300">
<Label className="text-xs text-popover-foreground">
{`Header {{1}}`}
</Label>
<Input
value={headerText}
onChange={(e) => setHeaderText(e.target.value)}
placeholder="Value for the header variable"
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
</div>
)}
{slots?.bodyVars.map((v, i) => (
<div key={v} className="space-y-1">
<Label className="text-xs text-slate-300">{`Body {{${v}}}`}</Label>
<Label className="text-xs text-popover-foreground">{`Body {{${v}}}`}</Label>
<Input
value={params[i] ?? ""}
onChange={(e) => {
@ -280,13 +283,13 @@ export function TemplatePicker({
setParams(next);
}}
placeholder={`Value for {{${v}}}`}
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
</div>
))}
{slots?.urlButtonSlots.map((slot) => (
<div key={slot.index} className="space-y-1">
<Label className="text-xs text-slate-300">
<Label className="text-xs text-popover-foreground">
{`URL button "${slot.text}" — value for `}{`{{1}}`}
</Label>
<Input
@ -298,9 +301,9 @@ export function TemplatePicker({
}))
}
placeholder="URL suffix value"
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
/>
<p className="text-[10px] text-slate-500 break-all">
<p className="text-[10px] text-muted-foreground break-all">
Final URL: {slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}")}
</p>
</div>
@ -314,7 +317,7 @@ export function TemplatePicker({
<Button
variant="outline"
onClick={resetSelection}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-popover-foreground hover:bg-muted"
>
<ArrowLeft className="h-4 w-4" />
Back
@ -331,7 +334,7 @@ export function TemplatePicker({
<Button
variant="outline"
onClick={() => handleOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-popover-foreground hover:bg-muted"
>
Cancel
</Button>

View File

@ -16,6 +16,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { ModeToggle } from "@/components/layout/mode-toggle";
const pageTitles: Record<string, string> = {
"/dashboard": "Dashboard",
@ -53,25 +54,28 @@ export function Header({ onOpenSidebar }: HeaderProps) {
"U";
return (
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-slate-800 bg-slate-950 px-4 lg:px-6">
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border bg-background px-4 lg:px-6">
<div className="flex min-w-0 items-center gap-2">
{/* Hamburger — mobile only. 44×44 hit target per Apple HIG. */}
<button
type="button"
onClick={onOpenSidebar}
aria-label="Open menu"
className="flex h-10 w-10 items-center justify-center rounded-md text-slate-300 transition-colors hover:bg-slate-800 hover:text-white lg:hidden"
className="flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
<h1 className="truncate text-base font-semibold text-white sm:text-lg">
<h1 className="truncate text-base font-semibold text-foreground sm:text-lg">
{title}
</h1>
</div>
<div className="flex items-center gap-1 sm:gap-2">
<ModeToggle />
<DropdownMenu>
<DropdownMenuTrigger
className="flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-slate-800/70 focus:bg-slate-800/70 focus:outline-none data-popup-open:bg-slate-800/70 sm:gap-3 sm:pl-1 sm:pr-3"
className="flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-muted/70 focus:bg-muted/70 focus:outline-none data-popup-open:bg-muted/70 sm:gap-3 sm:pl-1 sm:pr-3"
aria-label="Open account menu"
>
<Avatar className="size-8">
@ -85,29 +89,29 @@ export function Header({ onOpenSidebar }: HeaderProps) {
{initial}
</AvatarFallback>
</Avatar>
<span className="hidden text-sm font-medium text-white sm:inline">
<span className="hidden text-sm font-medium text-foreground sm:inline">
{profile?.full_name ?? "User"}
</span>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={6}
className="min-w-56 bg-slate-900 text-slate-100 ring-slate-700"
className="min-w-56 bg-popover text-popover-foreground ring-border"
>
<div className="px-2 py-1.5">
<p className="truncate text-sm font-medium text-white">
<p className="truncate text-sm font-medium text-foreground">
{profile?.full_name ?? "User"}
</p>
<p className="truncate text-xs text-slate-400">
<p className="truncate text-xs text-muted-foreground">
{profile?.email ?? ""}
</p>
</div>
<DropdownMenuSeparator className="bg-slate-800" />
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
render={
<Link
href="/settings?tab=profile"
className="text-slate-200 focus:bg-slate-800 focus:text-white"
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
/>
}
>
@ -118,23 +122,24 @@ export function Header({ onOpenSidebar }: HeaderProps) {
render={
<Link
href="/settings?tab=whatsapp"
className="text-slate-200 focus:bg-slate-800 focus:text-white"
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
/>
}
>
<SettingsIcon className="size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-800" />
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
onClick={signOut}
className="text-slate-200 focus:bg-slate-800 focus:text-white"
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
>
<LogOut className="size-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
);
}

View File

@ -0,0 +1,37 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "@/hooks/use-theme";
import { cn } from "@/lib/utils";
/**
* Light/dark mode toggle — a single icon button that flips the app
* between the two modes. Sun shows in light mode (click → go dark),
* moon shows in dark mode (click → go light); the label always names
* the destination so screen-reader users hear what the click does.
*
* 40×40 hit target to match the header's other touch controls.
*/
export function ModeToggle({ className }: { className?: string }) {
const { mode, toggleMode } = useTheme();
const goingTo = mode === "dark" ? "light" : "dark";
return (
<button
type="button"
onClick={toggleMode}
aria-label={`Switch to ${goingTo} mode`}
title={`Switch to ${goingTo} mode`}
className={cn(
"flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
className,
)}
>
{mode === "dark" ? (
<Moon className="h-5 w-5" />
) : (
<Sun className="h-5 w-5" />
)}
</button>
);
}

View File

@ -54,14 +54,14 @@ const ROLE_CHIP: Record<
label: "Agent",
// Neutral slate: the operational default.
className:
"border-slate-700 bg-slate-800 text-slate-300",
"border-border bg-muted text-foreground",
},
viewer: {
icon: User,
label: "Viewer",
// Muted slate: read-only role; visually quieter than agent.
className:
"border-slate-800 bg-slate-900 text-slate-500",
"border-border bg-card text-muted-foreground",
},
};
import {
@ -161,7 +161,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
aria-label="Close menu"
onClick={onClose}
className={cn(
"fixed inset-0 z-30 bg-slate-950/70 backdrop-blur-sm transition-opacity lg:hidden",
"fixed inset-0 z-30 bg-background/70 backdrop-blur-sm transition-opacity lg:hidden",
open
? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0",
@ -171,7 +171,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
<aside
className={cn(
// Mobile: fixed drawer that slides in from the left.
"fixed inset-y-0 left-0 z-40 flex h-full w-64 flex-col border-r border-slate-800 bg-slate-900",
"fixed inset-y-0 left-0 z-40 flex h-full w-64 flex-col border-r border-border bg-card",
"transition-transform duration-200 ease-out will-change-transform",
open ? "translate-x-0" : "-translate-x-full",
// Desktop: static, always visible — reset all the mobile framing.
@ -181,12 +181,12 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
>
{/* Logo row. On mobile we put a close button here; on desktop the
close button is hidden since the sidebar is always-visible. */}
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-slate-800 px-4">
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border px-4">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<MessageSquare className="h-4 w-4" />
</div>
<span className="text-sm font-semibold text-white">
<span className="text-sm font-semibold text-foreground">
CRM Template for WhatsApp
</span>
</Link>
@ -194,7 +194,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
type="button"
onClick={onClose}
aria-label="Close menu"
className="flex h-9 w-9 items-center justify-center rounded-md text-slate-400 hover:bg-slate-800 hover:text-white lg:hidden"
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground lg:hidden"
>
<X className="h-5 w-5" />
</button>
@ -227,7 +227,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive
? "bg-primary/10 text-primary"
: "text-slate-400 hover:bg-slate-800 hover:text-white",
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<item.icon className="h-4 w-4" />
@ -263,7 +263,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
})}
</ul>
<div className="my-4 border-t border-slate-800" />
<div className="my-4 border-t border-border" />
<ul className="flex flex-col gap-1">
{bottomNavItems.map((item) => {
@ -276,7 +276,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive
? "bg-primary/10 text-primary"
: "text-slate-400 hover:bg-slate-800 hover:text-white",
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
<item.icon className="h-4 w-4" />
@ -289,7 +289,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
</nav>
{/* User section */}
<div className="shrink-0 border-t border-slate-800 p-3">
<div className="shrink-0 border-t border-border p-3">
{/* Account name display — surfaced only when the account
name differs from the user's own name (see
`showAccountStrip`). For a default solo account the two
@ -297,7 +297,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
below; for renamed or shared accounts it tells the user
which account they're acting in. */}
{showAccountStrip && account?.name ? (
<div className="mb-2 flex items-center gap-2 px-3 text-xs text-slate-500">
<div className="mb-2 flex items-center gap-2 px-3 text-xs text-muted-foreground">
<UsersRound className="size-3.5 shrink-0" />
{/* `title=` exposes the full name on hover when it
gets truncated (long account names + narrow
@ -326,7 +326,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
</div>
) : null}
<DropdownMenu>
<DropdownMenuTrigger className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-slate-800/60 focus:bg-slate-800/60 focus:outline-none data-popup-open:bg-slate-800/60">
<DropdownMenuTrigger className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-muted/60 focus:bg-muted/60 focus:outline-none data-popup-open:bg-muted/60">
<Avatar className="size-8 shrink-0">
{profile?.avatar_url ? (
<AvatarImage
@ -341,10 +341,10 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-white">
<p className="truncate text-sm font-medium text-foreground">
{profile?.full_name ?? "User"}
</p>
<p className="truncate text-xs text-slate-400">
<p className="truncate text-xs text-muted-foreground">
{profile?.email ?? ""}
</p>
</div>
@ -353,14 +353,14 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
align="end"
side="top"
sideOffset={6}
className="min-w-56 bg-slate-900 text-slate-100 ring-slate-700"
className="min-w-56 bg-popover text-popover-foreground ring-border"
>
<DropdownMenuItem
render={
<Link
href="/settings?tab=profile"
onClick={onClose}
className="text-slate-200 focus:bg-slate-800 focus:text-white"
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
/>
}
>
@ -372,17 +372,17 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
<Link
href="/settings?tab=whatsapp"
onClick={onClose}
className="text-slate-200 focus:bg-slate-800 focus:text-white"
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
/>
}
>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-800" />
<DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem
onClick={signOut}
className="text-slate-200 focus:bg-slate-800 focus:text-white"
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
>
<LogOut className="size-4" />
Sign out

View File

@ -39,10 +39,10 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
e.stopPropagation();
onEdit(deal);
}}
className={`group relative w-full cursor-pointer rounded-xl border border-slate-700/50 bg-slate-800/70 pl-4 pr-3 py-3 text-left shadow-sm transition-all ${
className={`group relative w-full cursor-pointer rounded-xl border border-border/50 bg-muted/70 pl-4 pr-3 py-3 text-left shadow-sm transition-all ${
isOverlay
? "shadow-xl"
: "hover:-translate-y-0.5 hover:border-slate-600 hover:bg-slate-800 hover:shadow-lg"
: "hover:-translate-y-0.5 hover:border-border hover:bg-muted hover:shadow-lg"
}`}
>
{/* 4px left accent bar using stage color */}
@ -53,7 +53,7 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
/>
<div className="flex items-start justify-between gap-2">
<h4 className="flex-1 text-sm font-semibold leading-snug text-white break-words">
<h4 className="flex-1 text-sm font-semibold leading-snug text-foreground break-words">
{deal.title}
</h4>
{deal.status === "won" && (
@ -72,10 +72,10 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
{/* Contact row */}
<div className="mt-2 flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-slate-700 text-[10px] font-semibold text-slate-200">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-foreground">
{initials(deal.contact?.name, deal.contact?.phone)}
</span>
<span className="truncate text-xs text-slate-400">{contactLabel}</span>
<span className="truncate text-xs text-muted-foreground">{contactLabel}</span>
</div>
<div className="mt-2 flex items-center justify-between">
@ -83,7 +83,7 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
{formatCurrency(deal.value, deal.currency)}
</span>
{deal.expected_close_date && (
<span className="flex items-center gap-1 text-[11px] text-slate-500">
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Calendar className="h-3 w-3" />
{formatDate(deal.expected_close_date)}
</span>

View File

@ -247,32 +247,32 @@ export function DealForm({
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg w-full p-0"
className="bg-popover border-border text-popover-foreground sm:max-w-lg w-full p-0"
>
<div className="flex h-full flex-col">
<SheetHeader className="border-b border-slate-700/50 p-4">
<SheetTitle className="text-white">
<SheetHeader className="border-b border-border/50 p-4">
<SheetTitle className="text-popover-foreground">
{deal ? "Edit Deal" : "New Deal"}
</SheetTitle>
</SheetHeader>
<div className="flex-1 overflow-y-auto p-4 space-y-4">
<div className="grid gap-2">
<Label className="text-slate-300">Title</Label>
<Label className="text-muted-foreground">Title</Label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Deal title"
className="border-slate-700 bg-slate-800 text-white"
className="border-border bg-muted text-foreground"
/>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Contact</Label>
<Label className="text-muted-foreground">Contact</Label>
<select
value={contactId}
onChange={(e) => setContactId(e.target.value)}
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary focus:ring-1 focus:ring-primary"
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
>
<option value="">Select a contact</option>
{contacts.map((c) => (
@ -295,24 +295,24 @@ export function DealForm({
<div className="grid grid-cols-[1fr_110px] gap-3">
<div className="grid gap-2">
<Label className="text-slate-300">Value</Label>
<Label className="text-muted-foreground">Value</Label>
<div className="relative">
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
type="number"
value={value}
onChange={(e) => setValue(e.target.value)}
placeholder="0"
className="border-slate-700 bg-slate-800 pl-7 text-white"
className="border-border bg-muted pl-7 text-foreground"
/>
</div>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Currency</Label>
<Label className="text-muted-foreground">Currency</Label>
<select
value={currency}
onChange={(e) => setCurrency(e.target.value)}
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary"
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary"
>
{CURRENCIES.map((c) => (
<option key={c.code} value={c.code}>
@ -324,21 +324,21 @@ export function DealForm({
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Expected Close Date</Label>
<Label className="text-muted-foreground">Expected Close Date</Label>
<Input
type="date"
value={expectedCloseDate}
onChange={(e) => setExpectedCloseDate(e.target.value)}
className="border-slate-700 bg-slate-800 text-white"
className="border-border bg-muted text-foreground"
/>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Stage</Label>
<Label className="text-muted-foreground">Stage</Label>
<select
value={stageId}
onChange={(e) => setStageId(e.target.value)}
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary"
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary"
>
{stages.map((s) => (
<option key={s.id} value={s.id}>
@ -349,11 +349,11 @@ export function DealForm({
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Assigned To</Label>
<Label className="text-muted-foreground">Assigned To</Label>
<select
value={assignedTo}
onChange={(e) => setAssignedTo(e.target.value)}
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary"
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary"
>
<option value="">Unassigned</option>
{profiles.map((p) => (
@ -365,18 +365,18 @@ export function DealForm({
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Notes</Label>
<Label className="text-muted-foreground">Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add notes..."
className="min-h-[100px] border-slate-700 bg-slate-800 text-white"
className="min-h-[100px] border-border bg-muted text-foreground"
/>
</div>
{deal && (
<div className="space-y-2 rounded-lg border border-slate-700 bg-slate-900/50 p-3">
<p className="text-xs font-medium uppercase tracking-wider text-slate-400">
<div className="space-y-2 rounded-lg border border-border bg-muted/50 p-3">
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Status
</p>
<div className="flex gap-2">
@ -417,7 +417,7 @@ export function DealForm({
variant="ghost"
onClick={() => handleStatusChange("open")}
disabled={!!statusAction}
className="w-full text-slate-400 hover:text-white"
className="w-full text-muted-foreground hover:text-foreground"
>
Reopen deal
</Button>
@ -426,12 +426,12 @@ export function DealForm({
)}
</div>
<div className="border-t border-slate-700/50 bg-slate-900/80 p-4">
<div className="border-t border-border/50 bg-popover/80 p-4">
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="flex-1 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
className="flex-1 border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
@ -453,7 +453,7 @@ export function DealForm({
type="button"
onClick={() => setConfirmDelete(false)}
disabled={deleting}
className="rounded px-2 py-1 text-slate-300 hover:bg-slate-800"
className="rounded px-2 py-1 text-muted-foreground hover:bg-muted"
>
Cancel
</button>

View File

@ -93,9 +93,9 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
return (
<TooltipProvider>
<div className="grid grid-cols-2 gap-3 rounded-xl border border-slate-800 bg-slate-900/60 p-4 sm:grid-cols-3 xl:grid-cols-6">
<div className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-card/60 p-4 sm:grid-cols-3 xl:grid-cols-6">
<Metric
icon={<BarChart3 className="h-4 w-4 text-slate-400" />}
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
label="Total Deals"
value={String(stats.totalCount)}
tooltip="Count of every deal in this pipeline that isn't marked as Lost. Won deals are still included."
@ -147,8 +147,8 @@ function Metric({
tooltip: string;
}) {
return (
<div className="rounded-lg bg-slate-800/50 p-3">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-slate-400">
<div className="rounded-lg bg-muted/50 p-3">
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{icon}
<span>{label}</span>
<Tooltip>
@ -157,7 +157,7 @@ function Metric({
<button
type="button"
aria-label={`How ${label} is calculated`}
className="ml-auto text-slate-500 hover:text-slate-300 focus:outline-none"
className="ml-auto text-muted-foreground hover:text-foreground focus:outline-none"
/>
}
>
@ -168,7 +168,7 @@ function Metric({
</TooltipContent>
</Tooltip>
</div>
<p className="mt-1 text-base font-semibold text-white">{value}</p>
<p className="mt-1 text-base font-semibold text-foreground">{value}</p>
</div>
);
}

View File

@ -164,7 +164,7 @@ export function PipelineBoard({
@media (hover: hover) and (pointer: fine) {
.pipeline-scroll {
scrollbar-width: thin;
scrollbar-color: rgb(51 65 85) transparent;
scrollbar-color: var(--border) transparent;
}
.pipeline-scroll::-webkit-scrollbar {
height: 8px;
@ -173,11 +173,11 @@ export function PipelineBoard({
background: transparent;
}
.pipeline-scroll::-webkit-scrollbar-thumb {
background-color: rgb(51 65 85);
background-color: var(--border);
border-radius: 9999px;
}
.pipeline-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgb(71 85 105);
background-color: var(--muted-foreground);
}
}
`}</style>
@ -209,21 +209,21 @@ function StageColumn({
// restore the flex-1 share-the-row behavior. The droppable ref is
// on the inner messages region below — intentionally NOT here, so
// a drag over the column header doesn't highlight the whole column.
<div className="flex w-[85vw] min-w-[260px] max-w-[320px] shrink-0 snap-start flex-col rounded-xl border border-slate-800 bg-slate-900/60 p-4 lg:w-auto lg:max-w-none lg:flex-1 lg:basis-[260px] lg:shrink lg:snap-none">
<div className="flex w-[85vw] min-w-[260px] max-w-[320px] shrink-0 snap-start flex-col rounded-xl border border-border bg-card/60 p-4 lg:w-auto lg:max-w-none lg:flex-1 lg:basis-[260px] lg:shrink lg:snap-none">
{/* 3px colored top border — sits above the column's padding */}
<div
className="-mx-4 -mt-4 h-[3px] rounded-t-xl"
style={{ backgroundColor: stage.color }}
/>
<div className="flex items-center justify-between pt-3">
<h3 className="truncate text-sm font-semibold text-white">
<h3 className="truncate text-sm font-semibold text-foreground">
{stage.name}
</h3>
<span className="shrink-0 rounded-full bg-slate-800 px-2 py-0.5 text-[11px] font-medium text-slate-300">
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
{deals.length}
</span>
</div>
<p className="text-xs text-slate-400">
<p className="text-xs text-muted-foreground">
{formatCurrency(totalValue, currency)}
</p>
@ -236,7 +236,7 @@ function StageColumn({
}`}
>
{deals.length === 0 ? (
<div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-slate-700 py-10 text-xs text-slate-500">
<div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-border py-10 text-xs text-muted-foreground">
Drop a deal here
</div>
) : (
@ -255,7 +255,7 @@ function StageColumn({
variant="ghost"
size="sm"
onClick={() => onAddDeal(stage.id)}
className="mt-3 w-full justify-start border border-dashed border-slate-700 bg-transparent text-slate-400 hover:border-slate-600 hover:bg-slate-800 hover:text-white"
className="mt-3 w-full justify-start border border-dashed border-border bg-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground"
>
<Plus className="mr-1 h-3 w-3" />
Add Deal

View File

@ -199,9 +199,9 @@ export function PipelineSettings({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700 max-h-[85vh] overflow-y-auto">
<DialogContent className="sm:max-w-md bg-popover border-border max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white">Manage Pipeline</DialogTitle>
<DialogTitle className="text-popover-foreground">Manage Pipeline</DialogTitle>
</DialogHeader>
{showDeleteConfirm ? (
@ -212,7 +212,7 @@ export function PipelineSettings({
<p className="text-sm font-medium text-red-400">
Delete Pipeline
</p>
<p className="mt-1 text-xs text-slate-400">
<p className="mt-1 text-xs text-muted-foreground">
This will archive all deals in this pipeline. This cannot be
undone.
</p>
@ -222,7 +222,7 @@ export function PipelineSettings({
<Button
variant="outline"
onClick={() => setShowDeleteConfirm(false)}
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
@ -239,16 +239,16 @@ export function PipelineSettings({
<>
<div className="grid gap-4 py-2">
<div className="grid gap-2">
<Label className="text-slate-300">Pipeline Name</Label>
<Label className="text-muted-foreground">Pipeline Name</Label>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
className="border-slate-700 bg-slate-800 text-white"
className="border-border bg-muted text-foreground"
/>
</div>
<div className="grid gap-2">
<Label className="text-slate-300">Stages</Label>
<Label className="text-muted-foreground">Stages</Label>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
@ -292,7 +292,9 @@ export function PipelineSettings({
style={{
backgroundColor: color,
borderColor:
newStageColor === color ? "white" : "transparent",
newStageColor === color
? "var(--foreground)"
: "transparent",
}}
aria-label={`Pick color ${color}`}
/>
@ -303,7 +305,7 @@ export function PipelineSettings({
value={newStageName}
onChange={(e) => setNewStageName(e.target.value)}
placeholder="New stage name"
className="border-slate-700 bg-slate-800 text-sm text-white"
className="border-border bg-muted text-sm text-foreground"
onKeyDown={(e) => {
if (e.key === "Enter") handleAddStage();
}}
@ -313,7 +315,7 @@ export function PipelineSettings({
size="sm"
onClick={handleAddStage}
disabled={!newStageName.trim()}
className="shrink-0 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
className="shrink-0 border-border bg-transparent text-muted-foreground hover:bg-muted"
>
<Plus className="mr-1 h-3 w-3" />
Add
@ -324,14 +326,14 @@ export function PipelineSettings({
<Button
variant="outline"
onClick={onCreateNewPipeline}
className="w-full border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
className="w-full border-border bg-transparent text-muted-foreground hover:bg-muted"
>
<Plus className="mr-1 h-3 w-3" />
Create a new pipeline
</Button>
</div>
<DialogFooter className="border-slate-700 bg-slate-900/50">
<DialogFooter className="border-border bg-popover/50">
<Button
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
@ -342,7 +344,7 @@ export function PipelineSettings({
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
@ -387,13 +389,13 @@ function SortableStageRow({
<div
ref={setNodeRef}
style={style}
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800 p-2"
className="flex items-center gap-2 rounded-lg border border-border bg-muted p-2"
>
<button
type="button"
{...attributes}
{...listeners}
className="cursor-grab touch-none text-slate-500 hover:text-slate-300 active:cursor-grabbing"
className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
aria-label="Drag to reorder"
>
<GripVertical className="h-4 w-4" />
@ -402,13 +404,13 @@ function SortableStageRow({
<Input
value={stage.name}
onChange={(e) => onNameChange(e.target.value)}
className="h-7 flex-1 border-transparent bg-transparent text-sm text-white focus:border-slate-600"
className="h-7 flex-1 border-transparent bg-transparent text-sm text-foreground focus:border-border"
/>
<Button
variant="ghost"
size="icon-xs"
onClick={onRemove}
className="text-slate-400 hover:text-red-400"
className="text-muted-foreground hover:text-red-400"
>
<Trash2 className="h-3 w-3" />
</Button>
@ -431,14 +433,14 @@ function ColorSwatch({
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="h-4 w-4 rounded-full border border-slate-600"
className="h-4 w-4 rounded-full border border-border"
style={{ backgroundColor: value }}
aria-label="Change color"
/>
{open && (
<>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
<div className="absolute left-0 top-6 z-20 flex flex-wrap gap-1 rounded-lg border border-slate-700 bg-slate-900 p-2 shadow-lg w-36">
<div className="absolute left-0 top-6 z-20 flex flex-wrap gap-1 rounded-lg border border-border bg-popover p-2 shadow-lg w-36">
{colors.map((c) => (
<button
key={c}
@ -450,7 +452,8 @@ function ColorSwatch({
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
style={{
backgroundColor: c,
borderColor: c === value ? "white" : "transparent",
borderColor:
c === value ? "var(--foreground)" : "transparent",
}}
/>
))}

View File

@ -0,0 +1,40 @@
import { cn } from "@/lib/utils";
import type { PresenceStatus } from "@/lib/presence";
// Single source of truth for presence colours. Semantic accents
// (emerald / amber / muted), mirroring the role-chip palette already
// used across settings, so they're intentionally not tokenized.
export const PRESENCE_DOT_CLASS: Record<PresenceStatus, string> = {
online: "bg-emerald-500",
away: "bg-amber-500",
offline: "bg-muted-foreground/50",
};
/**
* A small presence dot. Used inline (e.g. the inbox Assign dropdown)
* and, with `asAvatarBadge`-style positioning supplied via className,
* layered onto an avatar. `label` powers the native title/aria for
* the lightweight inline case; the roster wraps it in a real Tooltip.
*/
export function PresenceDot({
status,
label,
className,
}: {
status: PresenceStatus;
label?: string;
className?: string;
}) {
return (
<span
role={label ? "img" : undefined}
aria-label={label}
title={label}
className={cn(
"inline-block size-2 shrink-0 rounded-full",
PRESENCE_DOT_CLASS[status],
className,
)}
/>
);
}

View File

@ -0,0 +1,105 @@
"use client";
import { useEffect, useRef } from "react";
import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth";
import { HEARTBEAT_MS, IDLE_AFTER_MS, type StoredPresence } from "@/lib/presence";
/**
* PresenceHeartbeat — headless. Mount ONCE per signed-in dashboard tab
* (in the dashboard shell, below the auth gate). Reports this tab's
* presence to the `member_presence` table via the `touch_presence` RPC
* roughly every HEARTBEAT_MS.
*
* The client only ever reports 'online' or 'away':
* - 'away' when the tab is hidden, or no user input for IDLE_AFTER_MS
* - 'online' otherwise
* It keeps heartbeating while away (so the row stays fresh, i.e. not
* offline). When the tab closes the beats simply stop and viewers derive
* 'offline' from staleness — no unreliable unload write needed.
*/
export function PresenceHeartbeat() {
const { accountId } = useAuth();
// 0 = "never recorded"; set on mount so we don't read the clock during
// render (impure). Until the effect runs the tab counts as active.
const lastActivityRef = useRef<number>(0);
useEffect(() => {
// Hold off until the account is known. Beating during the brief
// window on a fresh signup — authed but profile/account row not yet
// created — would make touch_presence raise "No account for caller"
// and log a spurious error. The effect re-runs once accountId lands.
if (!accountId) return;
const supabase = createClient();
let cancelled = false;
let lastBeatAt = 0;
lastActivityRef.current = Date.now();
const markActive = () => {
lastActivityRef.current = Date.now();
};
const currentStatus = (): StoredPresence => {
if (typeof document !== "undefined" && document.hidden) return "away";
if (Date.now() - lastActivityRef.current > IDLE_AFTER_MS) return "away";
return "online";
};
const beat = async () => {
if (cancelled) return;
// Coalesce bursts: a tab refocus fires visibilitychange AND focus
// together, so skip a beat within 1s of the last to avoid two RPCs
// in the same frame. The 30s interval is never affected.
const t = Date.now();
if (t - lastBeatAt < 1_000) return;
lastBeatAt = t;
const { error } = await supabase.rpc("touch_presence", {
p_status: currentStatus(),
});
if (error && !cancelled) {
// Non-fatal: presence is best-effort. Log once per failure so a
// misconfigured RPC is visible without spamming.
console.error("[PresenceHeartbeat] touch_presence failed:", error.message);
}
};
// Activity listeners. `passive` so we never block scroll/input.
const activityEvents: (keyof DocumentEventMap)[] = [
"mousemove",
"keydown",
"pointerdown",
"scroll",
];
activityEvents.forEach((e) =>
document.addEventListener(e, markActive, { passive: true }),
);
// Returning to the tab should beat immediately so a member flips
// back to online without a 30s wait. The debounce in beat() absorbs
// the visibilitychange + focus double-fire.
const onReturn = () => {
if (!document.hidden) markActive();
void beat();
};
document.addEventListener("visibilitychange", onReturn);
window.addEventListener("focus", onReturn);
void beat();
const interval = setInterval(() => void beat(), HEARTBEAT_MS);
return () => {
cancelled = true;
clearInterval(interval);
activityEvents.forEach((e) =>
document.removeEventListener(e, markActive),
);
document.removeEventListener("visibilitychange", onReturn);
window.removeEventListener("focus", onReturn);
};
}, [accountId]);
return null;
}

View File

@ -0,0 +1,481 @@
'use client';
// ============================================================
// ApiKeysSettings — Settings → API keys
//
// Manage the credentials that authenticate the public REST API
// (`/api/v1/*`). Any member sees the roster (read-only); admin+ can
// mint and revoke (gated by <RequireRole min="admin"> here and the
// admin-only API routes + RLS on the server).
//
// One-time reveal: a freshly-minted key's plaintext is shown ONCE in
// the creation dialog. After it closes, only the prefix remains —
// the server stores just the hash. The UI states this explicitly so
// the absence of a "copy again" button reads as intentional, not a
// bug (same lesson as the invite-link flow).
// ============================================================
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { RequireRole } from '@/components/auth/require-role';
import { useAuth } from '@/hooks/use-auth';
import {
API_SCOPES,
SCOPE_DESCRIPTIONS,
type ApiScope,
} from '@/lib/api-keys/scopes';
import { SettingsPanelHead } from './settings-panel-head';
interface ApiKey {
id: string;
name: string;
key_prefix: string;
scopes: string[];
last_used_at: string | null;
expires_at: string | null;
revoked_at: string | null;
created_at: string;
}
function fmtDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
function keyStatus(k: ApiKey): 'active' | 'revoked' | 'expired' {
if (k.revoked_at) return 'revoked';
if (k.expires_at && new Date(k.expires_at).getTime() <= Date.now())
return 'expired';
return 'active';
}
export function ApiKeysSettings() {
const { canEditSettings } = useAuth();
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [revoking, setRevoking] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const res = await fetch('/api/account/api-keys', { cache: 'no-store' });
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to load API keys');
return;
}
const data = (await res.json()) as { keys: ApiKey[] };
setKeys(data.keys);
} catch (err) {
console.error('[ApiKeysSettings] load error:', err);
toast.error('Could not reach the server');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
async function handleRevoke(key: ApiKey) {
setRevoking(key.id);
try {
const res = await fetch(`/api/account/api-keys/${key.id}`, {
method: 'DELETE',
});
if (!res.ok) {
const payload = await res.json().catch(() => ({}));
toast.error(payload.error || 'Failed to revoke key');
return;
}
toast.success(`Revoked "${key.name}"`);
// Reflect the revoke locally without a refetch.
setKeys((prev) =>
prev.map((k) =>
k.id === key.id ? { ...k, revoked_at: new Date().toISOString() } : k
)
);
} catch (err) {
console.error('[ApiKeysSettings] revoke error:', err);
toast.error('Could not reach the server');
} finally {
setRevoking(null);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="text-primary size-6 animate-spin" />
</div>
);
}
return (
<section className="animate-in fade-in-50 space-y-6 duration-200">
<SettingsPanelHead
title="API keys"
description={
<>
Keys authenticate the public REST API (
<code className="text-xs">/api/v1</code>) so you can build your own
automations. Send them as{' '}
<code className="text-xs">Authorization: Bearer &lt;key&gt;</code>.
</>
}
action={
<RequireRole min="admin">
<Button onClick={() => setCreateOpen(true)}>
<Plus className="size-4" />
New API key
</Button>
</RequireRole>
}
/>
{keys.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-10 text-center">
<KeyRound className="text-muted-foreground size-6" />
<p className="text-muted-foreground mt-2 text-sm">
No API keys yet.
</p>
{canEditSettings ? (
<p className="text-muted-foreground mt-1 text-xs">
Click <span className="text-foreground">New API key</span> to
create one.
</p>
) : (
<p className="text-muted-foreground mt-1 text-xs">
Ask an admin to create one.
</p>
)}
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-0">
<ul className="divide-border divide-y">
{keys.map((k) => {
const status = keyStatus(k);
const inactive = status !== 'active';
return (
<li
key={k.id}
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={`truncate text-sm font-medium ${
inactive
? 'text-muted-foreground line-through'
: 'text-foreground'
}`}
>
{k.name}
</span>
{status === 'revoked' && (
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
Revoked
</Badge>
)}
{status === 'expired' && (
<Badge className="border-border bg-muted text-muted-foreground text-[10px] tracking-wide uppercase">
Expired
</Badge>
)}
</div>
<p className="text-muted-foreground mt-0.5 font-mono text-xs">
{k.key_prefix}…
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{k.scopes.length === 0 ? (
<span className="text-muted-foreground text-xs">
No scopes
</span>
) : (
k.scopes.map((s) => (
<Badge
key={s}
className="border-border bg-muted text-muted-foreground text-[10px]"
>
{s}
</Badge>
))
)}
</div>
<p className="text-muted-foreground mt-1.5 text-xs">
Created {fmtDate(k.created_at)}
{' · '}
{k.last_used_at
? `last used ${fmtDate(k.last_used_at)}`
: 'never used'}
{k.expires_at && status !== 'expired'
? ` · expires ${fmtDate(k.expires_at)}`
: ''}
</p>
</div>
{status === 'active' && (
<RequireRole min="admin">
<Button
variant="outline"
size="sm"
onClick={() => handleRevoke(k)}
disabled={revoking === k.id}
className="self-start border-red-500/40 bg-red-500/10 text-red-300 hover:border-red-500/60 hover:bg-red-500/20 hover:text-red-200 sm:self-auto"
>
{revoking === k.id ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Trash2 className="size-4" />
)}
Revoke
</Button>
</RequireRole>
)}
</li>
);
})}
</ul>
</CardContent>
</Card>
)}
<CreateKeyDialog
open={createOpen}
onOpenChange={setCreateOpen}
onCreated={load}
/>
</section>
);
}
// ------------------------------------------------------------
// Create dialog — form → one-time plaintext reveal.
// ------------------------------------------------------------
function CreateKeyDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}) {
const [name, setName] = useState('');
const [scopes, setScopes] = useState<ApiScope[]>([]);
const [submitting, setSubmitting] = useState(false);
// Once set, we switch from the form to the reveal view.
const [createdKey, setCreatedKey] = useState<string | null>(null);
function reset() {
setName('');
setScopes([]);
setSubmitting(false);
setCreatedKey(null);
}
function toggleScope(scope: ApiScope, checked: boolean) {
setScopes((prev) =>
checked ? [...prev, scope] : prev.filter((s) => s !== scope)
);
}
async function handleCreate() {
const trimmed = name.trim();
if (!trimmed) {
toast.error('Give the key a name');
return;
}
setSubmitting(true);
try {
const res = await fetch('/api/account/api-keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: trimmed, scopes }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
toast.error(payload.error || 'Failed to create key');
return;
}
setCreatedKey(payload.plaintext as string);
onCreated();
} catch (err) {
console.error('[CreateKeyDialog] create error:', err);
toast.error('Could not reach the server');
} finally {
setSubmitting(false);
}
}
async function copyKey() {
if (!createdKey) return;
try {
await navigator.clipboard.writeText(createdKey);
toast.success('API key copied');
} catch {
toast.error('Copy failed — select and copy manually');
}
}
return (
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) reset();
onOpenChange(next);
}}
>
<DialogContent className="border-border bg-popover sm:max-w-md">
{createdKey ? (
<>
<DialogHeader>
<DialogTitle className="text-popover-foreground">
Copy your API key
</DialogTitle>
<DialogDescription className="text-muted-foreground">
This is the only time the full key is shown. Store it somewhere
safe — if you lose it, revoke it and create a new one.
</DialogDescription>
</DialogHeader>
<div className="space-y-1.5">
<Label className="text-muted-foreground">API key</Label>
<div className="flex gap-2">
<Input
readOnly
value={createdKey}
className="font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
<Button type="button" variant="outline" onClick={copyKey}>
<Copy className="size-4" />
Copy
</Button>
</div>
</div>
<DialogFooter>
<Button
onClick={() => {
reset();
onOpenChange(false);
}}
>
Done
</Button>
</DialogFooter>
</>
) : (
<>
<DialogHeader>
<DialogTitle className="text-popover-foreground">
New API key
</DialogTitle>
<DialogDescription className="text-muted-foreground">
Name it after the integration that will use it, and grant only
the scopes it needs.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-1.5">
<Label htmlFor="api-key-name" className="text-muted-foreground">
Name
</Label>
<Input
id="api-key-name"
value={name}
maxLength={80}
placeholder="e.g. Zapier automation"
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label className="text-muted-foreground">Scopes</Label>
<div className="border-border space-y-2 rounded-md border p-3">
{API_SCOPES.map((scope) => (
<label
key={scope}
className="flex cursor-pointer items-start gap-2.5"
>
<Checkbox
checked={scopes.includes(scope)}
onCheckedChange={(checked) =>
toggleScope(scope, checked === true)
}
className="mt-0.5"
/>
<span className="min-w-0">
<span className="text-foreground block font-mono text-xs">
{scope}
</span>
<span className="text-muted-foreground block text-xs">
{SCOPE_DESCRIPTIONS[scope]}
</span>
</span>
</label>
))}
</div>
<p className="text-muted-foreground text-xs">
A key with no scopes can still call{' '}
<code className="text-[11px]">GET /api/v1/me</code> to verify
it works.
</p>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
reset();
onOpenChange(false);
}}
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
<Button onClick={handleCreate} disabled={submitting}>
{submitting ? (
<>
<Loader2 className="size-4 animate-spin" />
Creating…
</>
) : (
'Create key'
)}
</Button>
</DialogFooter>
</>
)}
</DialogContent>
</Dialog>
);
}

View File

@ -1,35 +1,60 @@
"use client";
import { Check } from "lucide-react";
import { Check, Moon, Palette, SunMoon, Sun } from "lucide-react";
import { useTheme } from "@/hooks/use-theme";
import { THEMES, type ThemeId } from "@/lib/themes";
import { MODES, THEMES, type Mode, type ThemeId } from "@/lib/themes";
import { cn } from "@/lib/utils";
import { SettingsPanelHead } from "./settings-panel-head";
/**
* Appearance panel — color-theme picker.
* Appearance panel — light/dark mode + accent-color picker.
*
* Click a card → applies + persists immediately. No save button:
* the whole change is a single CSS-variable swap on <html>, there's
* nothing to roll back. The active card carries a check chip + a
* primary-tinted border so the current pick is obvious.
* Two independent controls: a mode toggle (light / dark) and the
* accent grid. Either applies + persists immediately. No save button:
* each change is a single attribute swap on <html>, there's nothing
* to roll back.
*
* Persistence: localStorage only (device-scoped). The boot script in
* layout.tsx replays the choice before first paint on subsequent
* layout.tsx replays both choices before first paint on subsequent
* loads.
*/
export function AppearancePanel() {
const { theme, setTheme } = useTheme();
const { theme, setTheme, mode, setMode } = useTheme();
return (
<section className="space-y-4">
<div>
<h2 className="text-lg font-semibold text-white">Color theme</h2>
<p className="mt-1 text-sm text-slate-400">
Pick the accent color used across the app. All themes stay
dark — only the primary color (buttons, active nav, badges)
changes. Saved to this device.
</p>
<section className="max-w-3xl animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Appearance"
description="Set the mode and accent colour used across the app. Saved to this device — try it, it changes live."
/>
<div className="space-y-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<SunMoon className="size-4 text-muted-foreground" />
Mode
</h3>
<div
role="radiogroup"
aria-label="Color mode"
className="grid max-w-md grid-cols-2 gap-3"
>
{MODES.map((m) => (
<ModeCard
key={m}
mode={m}
isActive={m === mode}
onPick={() => setMode(m)}
/>
))}
</div>
</div>
<div className="mt-8 space-y-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
<Palette className="size-4 text-muted-foreground" />
Accent color
</h3>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{THEMES.map((t) => (
@ -44,10 +69,55 @@ export function AppearancePanel() {
/>
))}
</div>
</div>
</section>
);
}
function ModeCard({
mode,
isActive,
onPick,
}: {
mode: Mode;
isActive: boolean;
onPick: () => void;
}) {
const isLight = mode === "light";
const Icon = isLight ? Sun : Moon;
return (
<button
type="button"
role="radio"
onClick={onPick}
aria-checked={isActive}
aria-label={`Use ${mode} mode`}
className={cn(
"flex items-center gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
isActive
? "border-primary/60 ring-2 ring-primary/40"
: "border-border hover:border-border hover:bg-muted/40",
)}
>
<span
aria-hidden
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-foreground"
>
<Icon className="h-4 w-4" />
</span>
<span className="flex-1 text-sm font-semibold capitalize text-foreground">
{mode}
</span>
{isActive && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
<Check className="h-3 w-3" />
Active
</span>
)}
</button>
);
}
function ThemeCard({
id,
name,
@ -73,7 +143,7 @@ function ThemeCard({
"flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
isActive
? "border-primary/60 ring-2 ring-primary/40"
: "border-slate-800 hover:border-slate-700 hover:bg-slate-800/40",
: "border-border hover:border-border hover:bg-muted/40",
)}
>
<div className="flex items-center justify-between">
@ -93,8 +163,8 @@ function ThemeCard({
)}
</div>
<div>
<div className="text-sm font-semibold text-white">{name}</div>
<div className="mt-1 text-xs leading-relaxed text-slate-400">
<div className="text-sm font-semibold text-foreground">{name}</div>
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">
{tagline}
</div>
</div>
@ -103,9 +173,9 @@ function ThemeCard({
aria-hidden
>
<span className="flex-1" style={{ background: swatch }} />
<span className="w-3 bg-slate-700" />
<span className="w-3 bg-slate-800" />
<span className="w-3 bg-slate-900" />
<span className="w-3 bg-muted-foreground/60" />
<span className="w-3 bg-muted" />
<span className="w-3 bg-card" />
</div>
<span className="sr-only">Theme id: {id}</span>
</button>

View File

@ -1,29 +1,43 @@
'use client';
import { Card, CardContent } from '@/components/ui/card';
import { Shield, SlidersHorizontal } from 'lucide-react';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager';
import { SettingsChip } from './settings-chip';
/**
* Settings → Custom Fields. Manage the account-wide custom contact field
* catalogue (the same panel the Contacts page exposes via a dialog). Writes
* are admin-gated by the caller and enforced by `custom_fields` RLS.
* Settings → Custom Fields card. Manages the account-wide custom
* contact field catalogue (the same panel the Contacts page exposes
* via a dialog). Writes are admin-gated by the caller and enforced by
* `custom_fields` RLS.
*/
export function CustomFieldsSettings() {
return (
<div className="mt-4 space-y-4">
<div>
<h2 className="text-lg font-semibold text-white">Custom fields</h2>
<p className="text-sm text-slate-400">
Define extra contact fields (e.g. ZIP code, lead source). They appear
on every contact and in the “Update Contact Field” automation action.
</p>
</div>
<Card className="border-slate-700 bg-slate-900 ring-0 ring-transparent">
<CardContent className="pt-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-foreground">
<SlidersHorizontal className="size-4 text-primary" />
Custom fields
<SettingsChip variant="admin" className="font-medium">
<Shield />
Admin
</SettingsChip>
</CardTitle>
<CardDescription className="text-muted-foreground">
Extra contact fields (e.g. ZIP code, lead source). They appear on
every contact and in the “Update Contact Field” automation action.
</CardDescription>
</CardHeader>
<CardContent>
<CustomFieldsPanel />
</CardContent>
</Card>
</div>
);
}

View File

@ -16,6 +16,7 @@ import {
CardTitle,
CardDescription,
} from "@/components/ui/card";
import { SettingsPanelHead } from "./settings-panel-head";
/**
* Deals settings — account-wide default currency.
@ -67,14 +68,18 @@ export function DealsSettings() {
}
return (
<section className="mt-4 space-y-4">
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
<section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Deals & currency"
description="The currency used for new deals and for pipeline and dashboard totals."
/>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<CardTitle className="flex items-center gap-2 text-foreground">
<Coins className="size-4 text-primary" />
Default currency
</CardTitle>
<CardDescription className="text-slate-400">
<CardDescription className="text-muted-foreground">
New deals default to this currency, and pipeline and
dashboard totals are shown in it. Existing deals keep the
currency they were saved with.
@ -82,12 +87,12 @@ export function DealsSettings() {
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-2 sm:max-w-xs">
<Label className="text-slate-300">Currency</Label>
<Label className="text-muted-foreground">Currency</Label>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
disabled={!canEditSettings || profileLoading}
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
>
{CURRENCIES.map((c) => (
<option key={c.code} value={c.code}>
@ -96,7 +101,7 @@ export function DealsSettings() {
))}
</select>
{!canEditSettings && (
<p className="text-xs text-slate-500">
<p className="text-xs text-muted-foreground">
Only account admins can change the default currency.
</p>
)}

View File

@ -0,0 +1,29 @@
'use client';
import { useCan } from '@/hooks/use-can';
import { CustomFieldsSettings } from './custom-fields-settings';
import { SettingsPanelHead } from './settings-panel-head';
import { TagManager } from './tag-manager';
/**
* "Fields & tags" section — merges the former Tags and Custom Fields
* tabs. Tags are visible to everyone; the custom-fields catalogue is
* account-wide config, so the card is admin-gated (mirroring the old
* hidden-tab behaviour). `custom_fields` RLS rejects non-admin writes
* regardless.
*/
export function FieldsAndTagsPanel() {
const canEditSettings = useCan('edit-settings');
return (
<section className="max-w-3xl animate-in fade-in-50 space-y-4 duration-200">
<SettingsPanelHead
title="Fields & tags"
description="Two ways to organize contacts: colour-coded tags for quick grouping, and custom fields for structured data."
/>
<TagManager />
{canEditSettings ? <CustomFieldsSettings /> : null}
</section>
);
}

View File

@ -185,20 +185,20 @@ export function InviteMemberDialog({
onOpenChange(next);
}}
>
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-md">
<DialogContent className="bg-popover border-border sm:max-w-md">
{result ? (
<>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<Sparkles className="size-4 text-primary" />
Invite created
</DialogTitle>
<DialogDescription className="text-slate-400">
<DialogDescription className="text-muted-foreground">
Share this link with your new teammate. They&apos;ll be able
to sign up (or sign in) and join the account as{' '}
<span className="font-medium text-slate-300">{result.role}</span>
<span className="font-medium text-muted-foreground">{result.role}</span>
. The link is valid for{' '}
<span className="font-medium text-slate-300">
<span className="font-medium text-muted-foreground">
{result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'}
</span>
.
@ -206,12 +206,12 @@ export function InviteMemberDialog({
</DialogHeader>
<div className="space-y-3 py-2">
<Label className="text-slate-300">Invite link</Label>
<Label className="text-muted-foreground">Invite link</Label>
<div className="flex gap-2">
<Input
readOnly
value={result.url}
className="bg-slate-800 border-slate-700 text-white font-mono text-xs"
className="bg-muted border-border text-foreground font-mono text-xs"
onFocus={(e) => e.currentTarget.select()}
/>
<Button
@ -250,7 +250,7 @@ export function InviteMemberDialog({
className={buttonVariants({
variant: 'outline',
className:
'w-full border-slate-700 text-slate-300 hover:bg-slate-800',
'w-full border-border text-muted-foreground hover:bg-muted',
})}
>
<MessageCircle className="size-4" />
@ -258,7 +258,7 @@ export function InviteMemberDialog({
</a>
</div>
<DialogFooter className="bg-slate-900 border-slate-700">
<DialogFooter className="bg-popover border-border">
<Button
onClick={() => onOpenChange(false)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
@ -270,8 +270,8 @@ export function InviteMemberDialog({
) : (
<>
<DialogHeader>
<DialogTitle className="text-white">Invite a teammate</DialogTitle>
<DialogDescription className="text-slate-400">
<DialogTitle className="text-popover-foreground">Invite a teammate</DialogTitle>
<DialogDescription className="text-muted-foreground">
Generate a one-time invite link. Share it via WhatsApp,
Slack, or any channel you like — no email service required.
</DialogDescription>
@ -279,12 +279,12 @@ export function InviteMemberDialog({
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label className="text-slate-300">Role</Label>
<Label className="text-muted-foreground">Role</Label>
<Select
value={role}
onValueChange={(v) => v && setRole(v as InviteRole)}
>
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
<SelectTrigger className="w-full bg-muted border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -293,18 +293,18 @@ export function InviteMemberDialog({
<SelectItem value="viewer">Viewer</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-slate-500">
<p className="text-xs text-muted-foreground">
{ROLE_DESCRIPTIONS[role]}
</p>
</div>
<div className="space-y-2">
<Label className="text-slate-300">Link valid for</Label>
<Label className="text-muted-foreground">Link valid for</Label>
<Select
value={expiry}
onValueChange={(v) => v && setExpiry(v)}
>
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
<SelectTrigger className="w-full bg-muted border-border text-foreground">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -318,29 +318,29 @@ export function InviteMemberDialog({
</div>
<div className="space-y-2">
<Label className="text-slate-300">
<Label className="text-muted-foreground">
Label{' '}
<span className="text-xs text-slate-500">(optional)</span>
<span className="text-xs text-muted-foreground">(optional)</span>
</Label>
<Input
placeholder="e.g. Sara — support team"
value={label}
onChange={(e) => setLabel(e.target.value)}
maxLength={MAX_LABEL_LEN}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
/>
<p className="text-xs text-slate-500">
<p className="text-xs text-muted-foreground">
Helps you remember who you sent the link to in the pending
list below.
</p>
</div>
</div>
<DialogFooter className="bg-slate-900 border-slate-700">
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => onOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>

View File

@ -25,20 +25,26 @@ import { useCallback, useEffect, useState } from 'react';
import { toast } from 'sonner';
import {
AlertTriangle,
Crown,
Loader2,
Mail,
MailX,
Plus,
Shield,
Trash2,
UserCog,
UserIcon,
UsersRound,
} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import {
Avatar,
AvatarBadge,
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
@ -58,8 +64,16 @@ import {
} from '@/components/ui/select';
import { RequireRole } from '@/components/auth/require-role';
import { useAuth } from '@/hooks/use-auth';
import { usePresence } from '@/hooks/use-presence';
import type { AccountRole } from '@/lib/auth/roles';
import { presenceLabel, summarize } from '@/lib/presence';
import {
PRESENCE_DOT_CLASS,
PresenceDot,
} from '@/components/presence/presence-dot';
import { InviteMemberDialog } from './invite-member-dialog';
import { SettingsPanelHead } from './settings-panel-head';
import { ROLE_META } from './role-meta';
interface Member {
user_id: string;
@ -86,38 +100,10 @@ const EDITABLE_ROLES: { value: AccountRole; label: string; hint: string }[] = [
{ value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' },
];
// Per-role chip metadata. The colour scale runs amber (owner —
// scarce, immutable) → primary (admin — significant) → slate
// (agent — operational default) → muted slate (viewer — read-
// only). Mirrors the sidebar's ROLE_CHIP so the two surfaces
// don't drift; once the surface stabilises this should hoist
// into a shared module.
const ROLE_CHIP: Record<
AccountRole,
{ icon: typeof Crown; label: string; className: string }
> = {
owner: {
icon: Crown,
label: 'Owner',
className:
'border-amber-500/40 bg-amber-500/10 text-amber-300',
},
admin: {
icon: Shield,
label: 'Admin',
className: 'border-primary/40 bg-primary/10 text-primary',
},
agent: {
icon: UserCog,
label: 'Agent',
className: 'border-slate-700 bg-slate-800 text-slate-300',
},
viewer: {
icon: UserIcon,
label: 'Viewer',
className: 'border-slate-800 bg-slate-900 text-slate-500',
},
};
// Per-role chip metadata (icon / label / colour) lives in the shared
// ROLE_META module so this roster and the Overview identity chip can't
// drift. The colour scale runs amber (owner — scarce, immutable) →
// primary (admin) → muted (agent / viewer).
function fmtDate(iso: string): string {
// Match the rest of the dashboard's locale-light formatting.
@ -140,6 +126,7 @@ function fmtExpiresIn(iso: string): string {
export function MembersTab() {
const { user, canManageMembers } = useAuth();
const { getPresence, getRow, now } = usePresence();
const [members, setMembers] = useState<Member[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
@ -292,37 +279,63 @@ export function MembersTab() {
}
return (
<div className="space-y-6 mt-4">
{/* Header + invite button */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-white">Account members</h2>
<p className="text-sm text-slate-400">
People with access to this account. Roles control what each
teammate can do.
</p>
</div>
<section className="animate-in fade-in-50 space-y-6 duration-200">
<SettingsPanelHead
title="Team members"
description="People with access to this account. Roles control what each teammate can do."
action={
<RequireRole min="admin">
<Button
onClick={() => setInviteOpen(true)}
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Button onClick={() => setInviteOpen(true)}>
<Plus className="size-4" />
Invite member
</Button>
</RequireRole>
}
/>
{/* Live presence summary across the roster. Updates without a
full refresh as heartbeats and the local re-derive tick land. */}
{members.length > 0 &&
(() => {
const counts = summarize(members.map((m) => getPresence(m.user_id)));
return (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="online" />
{counts.online} online
</span>
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="away" />
{counts.away} away
</span>
<span className="inline-flex items-center gap-1.5">
<PresenceDot status="offline" />
{counts.offline} offline
</span>
<span className="text-muted-foreground/70">
· {members.length} member{members.length === 1 ? '' : 's'}
</span>
</div>
);
})()}
{/* Roster */}
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
<Card>
<CardContent className="p-0">
<ul className="divide-y divide-slate-800">
<ul className="divide-y divide-border">
{members.map((member) => {
const roleMeta = ROLE_CHIP[member.role];
const roleMeta = ROLE_META[member.role];
const RoleIcon = roleMeta.icon;
const isSelf = member.user_id === user?.id;
const isOwnerRow = member.role === 'owner';
const isBusy = pendingMemberAction === member.user_id;
const presence = getPresence(member.user_id);
const presenceRow = getRow(member.user_id);
const presenceText = presenceLabel(
presence,
presenceRow?.last_seen_at ?? null,
now,
);
return (
<li
@ -335,6 +348,9 @@ export function MembersTab() {
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
>
<div className="flex min-w-0 flex-1 items-center gap-4">
<Tooltip>
<TooltipTrigger
render={
<Avatar className="size-9 shrink-0">
{member.avatar_url ? (
<AvatarImage
@ -347,21 +363,34 @@ export function MembersTab() {
.charAt(0)
.toUpperCase()}
</AvatarFallback>
{/* role+label so screen readers announce
presence — the hover tooltip alone isn't
reachable by keyboard/AT on a non-focusable
avatar. */}
<AvatarBadge
role="img"
aria-label={presenceText}
className={PRESENCE_DOT_CLASS[presence]}
/>
</Avatar>
}
/>
<TooltipContent>{presenceText}</TooltipContent>
</Tooltip>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-white">
<span className="truncate text-sm font-medium text-foreground">
{member.full_name || 'Unnamed'}
</span>
{isSelf && (
<Badge className="bg-slate-800 text-slate-300 border-slate-700 text-[10px] uppercase tracking-wide">
<Badge className="bg-muted text-muted-foreground border-border text-[10px] uppercase tracking-wide">
You
</Badge>
)}
</div>
{member.email && (
<p className="truncate text-xs text-slate-500">
<p className="truncate text-xs text-muted-foreground">
{member.email}
</p>
)}
@ -370,7 +399,7 @@ export function MembersTab() {
{/* Joined date stays desktop-only. The mobile row's
vertical density makes the joined date noise. */}
<div className="hidden sm:block text-right text-xs text-slate-500">
<div className="hidden sm:block text-right text-xs text-muted-foreground">
Joined {fmtDate(member.joined_at)}
</div>
@ -394,7 +423,7 @@ export function MembersTab() {
}
>
<SelectTrigger
className="w-32 bg-slate-800 border-slate-700 text-slate-200"
className="w-32 bg-muted border-border text-foreground"
disabled={isBusy}
>
<SelectValue />
@ -446,11 +475,11 @@ export function MembersTab() {
<RequireRole min="admin">
<div>
<div className="mb-2 flex items-center gap-2">
<UsersRound className="size-4 text-slate-400" />
<h3 className="text-sm font-semibold text-white">
<UsersRound className="size-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">
Pending invitations
</h3>
<Badge className="bg-slate-800 text-slate-400 border-slate-700">
<Badge className="bg-muted text-muted-foreground border-border">
{invitations.length}
</Badge>
</div>
@ -460,7 +489,7 @@ export function MembersTab() {
front (rather than letting the user discover it by
looking for a button) keeps it from feeling like a bug. */}
{invitations.length > 0 ? (
<p className="mb-3 text-xs text-slate-500">
<p className="mb-3 text-xs text-muted-foreground">
The plaintext invite URL is only shown once at creation
for security — to re-share, revoke the invite below and
create a new one.
@ -468,24 +497,24 @@ export function MembersTab() {
) : null}
{invitations.length === 0 ? (
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
<Card>
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
<Mail className="size-6 text-slate-600" />
<p className="mt-2 text-sm text-slate-400">
<Mail className="size-6 text-muted-foreground" />
<p className="mt-2 text-sm text-muted-foreground">
No pending invitations.
</p>
<p className="mt-1 text-xs text-slate-500">
Click <span className="text-slate-300">Invite member</span>{' '}
<p className="mt-1 text-xs text-muted-foreground">
Click <span className="text-muted-foreground">Invite member</span>{' '}
above to generate a shareable link.
</p>
</CardContent>
</Card>
) : (
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
<Card>
<CardContent className="p-0">
<ul className="divide-y divide-slate-800">
<ul className="divide-y divide-border">
{invitations.map((inv) => {
const inviteRoleMeta = ROLE_CHIP[inv.role];
const inviteRoleMeta = ROLE_META[inv.role];
const InviteRoleIcon = inviteRoleMeta.icon;
return (
<li
@ -494,7 +523,7 @@ export function MembersTab() {
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-white">
<span className="text-sm font-medium text-foreground">
{inv.label || 'Untitled invite'}
</span>
<span
@ -504,7 +533,7 @@ export function MembersTab() {
{inviteRoleMeta.label}
</span>
</div>
<p className="mt-0.5 text-xs text-slate-500">
<p className="mt-0.5 text-xs text-muted-foreground">
Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)}
</p>
</div>
@ -544,15 +573,15 @@ export function MembersTab() {
if (!open) setRemovingMember(null);
}}
>
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-sm">
<DialogContent className="bg-popover border-border sm:max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-white">
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
<AlertTriangle className="size-4 text-amber-400" />
Remove member
</DialogTitle>
<DialogDescription className="text-slate-400">
<DialogDescription className="text-muted-foreground">
Remove{' '}
<span className="font-medium text-slate-300">
<span className="font-medium text-muted-foreground">
{removingMember?.full_name || 'this teammate'}
</span>{' '}
from the account? They&apos;ll be signed out of this account
@ -560,11 +589,11 @@ export function MembersTab() {
login isn&apos;t deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter className="bg-slate-900 border-slate-700">
<DialogFooter className="bg-popover border-border">
<Button
variant="outline"
onClick={() => setRemovingMember(null)}
className="border-slate-700 text-slate-300 hover:bg-slate-800"
className="border-border text-muted-foreground hover:bg-muted"
>
Cancel
</Button>
@ -585,6 +614,6 @@ export function MembersTab() {
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</section>
);
}

View File

@ -81,13 +81,13 @@ export function PasswordForm() {
};
return (
<Card className="bg-slate-900/40 border-slate-800">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<CardTitle className="flex items-center gap-2 text-foreground">
<KeyRound className="size-4 text-primary" />
Password
</CardTitle>
<CardDescription className="text-slate-400">
<CardDescription className="text-muted-foreground">
Use at least {MIN_PASSWORD} characters. You will stay signed in on
this device after changing it.
</CardDescription>
@ -96,7 +96,7 @@ export function PasswordForm() {
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="current-password" className="text-slate-200">
<Label htmlFor="current-password" className="text-foreground">
Current password
</Label>
<Input
@ -112,7 +112,7 @@ export function PasswordForm() {
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="new-password" className="text-slate-200">
<Label htmlFor="new-password" className="text-foreground">
New password
</Label>
<Input
@ -127,7 +127,7 @@ export function PasswordForm() {
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password" className="text-slate-200">
<Label htmlFor="confirm-password" className="text-foreground">
Confirm new password
</Label>
<Input
@ -144,7 +144,7 @@ export function PasswordForm() {
</div>
{confirmError && (
<p className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-300">
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
{confirmError}
</p>
)}

View File

@ -14,13 +14,8 @@ import {
AvatarFallback,
AvatarImage,
} from '@/components/ui/avatar';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription,
} from '@/components/ui/card';
import { Card, CardContent } from '@/components/ui/card';
import { SettingsPanelHead } from './settings-panel-head';
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
const ALLOWED_MIME = new Set([
@ -210,17 +205,14 @@ export function ProfileForm() {
: '—';
return (
<Card className="bg-slate-900/40 border-slate-800">
<CardHeader>
<CardTitle className="text-white">Profile</CardTitle>
<CardDescription className="text-slate-400">
How you show up across the app. Your avatar and name appear in the
header, sidebar, and anywhere your teammates see you.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-6">
<section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Your profile"
description="How you show up across the app. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you."
/>
<form onSubmit={onSubmit} className="space-y-4">
<Card>
<CardContent className="space-y-6">
{/* Avatar row */}
<div className="flex flex-wrap items-center gap-5">
<Avatar size="lg" className="size-16">
@ -255,13 +247,13 @@ export function ProfileForm() {
variant="ghost"
onClick={onRemoveAvatar}
disabled={saving}
className="text-slate-400 hover:text-white"
className="text-muted-foreground hover:text-foreground"
>
<Trash2 className="size-4" />
Remove
</Button>
)}
<p className="w-full text-xs text-slate-500">
<p className="w-full text-xs text-muted-foreground">
PNG, JPG, WebP, or GIF. Up to 2 MB.
</p>
</div>
@ -269,7 +261,7 @@ export function ProfileForm() {
{/* Name */}
<div className="space-y-2">
<Label htmlFor="profile-full-name" className="text-slate-200">
<Label htmlFor="profile-full-name" className="text-foreground">
Display name
</Label>
<Input
@ -285,7 +277,7 @@ export function ProfileForm() {
{/* Email */}
<div className="space-y-2">
<Label htmlFor="profile-email" className="text-slate-200">
<Label htmlFor="profile-email" className="text-foreground">
Email
</Label>
<Input
@ -297,7 +289,7 @@ export function ProfileForm() {
required
/>
{emailChangePending && (
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-300">
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
<Mail className="mt-0.5 size-3.5 shrink-0" />
<span>
Check the inbox for <strong>{profile?.email}</strong> and{' '}
@ -309,24 +301,24 @@ export function ProfileForm() {
</div>
{/* Read-only block */}
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
<div className="rounded-lg border border-border bg-muted p-4">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Account details
</p>
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-slate-500">Role</dt>
<dd className="mt-0.5 font-mono text-slate-200">
<dt className="text-muted-foreground">Role</dt>
<dd className="mt-0.5 font-mono text-foreground">
{profile?.role ?? 'user'}
</dd>
</div>
<div>
<dt className="text-slate-500">Joined</dt>
<dd className="mt-0.5 text-slate-200">{joined}</dd>
<dt className="text-muted-foreground">Joined</dt>
<dd className="mt-0.5 text-foreground">{joined}</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-slate-500">User ID</dt>
<dd className="mt-0.5 break-all font-mono text-xs text-slate-400">
<dt className="text-muted-foreground">User ID</dt>
<dd className="mt-0.5 break-all font-mono text-xs text-muted-foreground">
{user?.id ?? '—'}
</dd>
</div>
@ -334,12 +326,15 @@ export function ProfileForm() {
</div>
{!profile && (
<p className="flex items-center gap-2 text-sm text-slate-400">
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<CircleAlert className="size-4" />
Loading your profile…
</p>
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button type="submit" disabled={saving || !dirty || !profile}>
{saving ? (
@ -353,7 +348,6 @@ export function ProfileForm() {
</Button>
</div>
</form>
</CardContent>
</Card>
</section>
);
}

View File

@ -0,0 +1,51 @@
import {
Crown,
Shield,
UserCog,
UserIcon,
type LucideIcon,
} from 'lucide-react';
import type { AccountRole } from '@/lib/auth/roles';
import type { ChipVariant } from './settings-chip';
/**
* Single source of truth for per-role chip metadata across settings
* surfaces (the Overview identity chip and the Members roster/invite
* chips). Previously duplicated in both files; hoisted here so a label,
* icon, or colour change lands once.
*
* `variant` drives the token-based <SettingsChip>; `className` is the
* inline Tailwind string the Members tab applies to its own spans.
*/
export const ROLE_META: Record<
AccountRole,
{ icon: LucideIcon; label: string; variant: ChipVariant; className: string }
> = {
owner: {
icon: Crown,
label: 'Owner',
variant: 'owner',
className: 'border-amber-500/40 bg-amber-500/10 text-amber-300',
},
admin: {
icon: Shield,
label: 'Admin',
variant: 'admin',
className: 'border-primary/40 bg-primary/10 text-primary',
},
agent: {
icon: UserCog,
label: 'Agent',
variant: 'muted',
className: 'border-border bg-muted text-muted-foreground',
},
viewer: {
icon: UserIcon,
label: 'Viewer',
variant: 'muted',
// Outline-only so it stays quieter than the filled Agent chip in
// both modes — bg-card would blend into a card surface in light mode.
className: 'border-border bg-transparent text-muted-foreground',
},
};

View File

@ -0,0 +1,24 @@
'use client';
import { PasswordForm } from './password-form';
import { SessionsCard } from './sessions-card';
import { SettingsPanelHead } from './settings-panel-head';
/**
* "Login & security" section — groups the former Profile-tab password
* and active-sessions cards into their own dedicated home.
*/
export function SecurityPanel() {
return (
<section className="max-w-2xl animate-in fade-in-50 duration-200">
<SettingsPanelHead
title="Login & security"
description="Change your password and sign out of your devices. These keep your account safe."
/>
<div className="space-y-4">
<PasswordForm />
<SessionsCard />
</div>
</section>
);
}

View File

@ -49,13 +49,13 @@ export function SessionsCard() {
return (
<>
<Card className="bg-slate-900/40 border-slate-800">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-white">
<CardTitle className="flex items-center gap-2 text-foreground">
<LogOut className="size-4 text-primary" />
Active sessions
</CardTitle>
<CardDescription className="text-slate-400">
<CardDescription className="text-muted-foreground">
Sign out of every device where you&apos;re logged in — including
this one. Useful if you lost a laptop or shared your password.
</CardDescription>

View File

@ -0,0 +1,64 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
/**
* Small status / role pill used across the settings redesign
* (Overview tiles, WhatsApp banner, the "Active" appearance markers).
*
* Status colours (emerald = good, amber = attention) follow the same
* Tailwind palette the members tab already uses for role chips — they
* are semantic accents, not neutrals, so they're intentionally not
* tokenized. Neutrals stay on design tokens.
*/
export type ChipVariant = 'owner' | 'admin' | 'ok' | 'warn' | 'muted';
const VARIANTS: Record<ChipVariant, string> = {
owner: 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-300',
admin: 'border-primary-soft-2 bg-primary-soft text-primary',
ok: 'border-emerald-500/35 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300',
warn: 'border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-300',
muted: 'border-border bg-muted text-muted-foreground',
};
export function SettingsChip({
variant = 'muted',
className,
children,
}: {
variant?: ChipVariant;
className?: string;
children: ReactNode;
}) {
return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-medium whitespace-nowrap [&_svg]:size-3.5',
VARIANTS[variant],
className,
)}
>
{children}
</span>
);
}
/** A small live status dot (e.g. WhatsApp connected indicator). */
export function StatusDot({
tone = 'ok',
className,
}: {
tone?: 'ok' | 'muted';
className?: string;
}) {
return (
<span
aria-hidden
className={cn(
'inline-block size-1.5 shrink-0 rounded-full',
tone === 'ok' ? 'bg-emerald-500' : 'bg-muted-foreground',
className,
)}
/>
);
}

View File

@ -0,0 +1,289 @@
'use client';
import { useEffect, useState, type ReactNode } from 'react';
import { ChevronRight, Loader2 } from 'lucide-react';
import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth';
import { useTheme } from '@/hooks/use-theme';
import { THEMES } from '@/lib/themes';
import { CURRENCIES } from '@/lib/currency';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { SECTION_META, type SettingsSection } from './settings-sections';
import { SettingsChip, StatusDot } from './settings-chip';
import { ROLE_META } from './role-meta';
interface OverviewCounts {
members: number | null;
pendingInvites: number | null;
templates: number | null;
templatesPending: number | null;
tags: number | null;
customFields: number | null;
}
interface WhatsAppStatus {
configured: boolean;
connected: boolean;
}
export function SettingsOverview({
onSelect,
}: {
onSelect: (section: SettingsSection) => void;
}) {
const { user, profile, accountId, accountRole, defaultCurrency, canManageMembers } =
useAuth();
const { mode, theme } = useTheme();
const [counts, setCounts] = useState<OverviewCounts | null>(null);
const [countsLoading, setCountsLoading] = useState(true);
// WhatsApp status is tracked separately: its health check decrypts the
// token and pings Meta, which is far slower than the cheap count
// queries. Gating it independently keeps a slow/flaky Meta round-trip
// from blanking the rest of the landing.
const [whatsapp, setWhatsapp] = useState<WhatsAppStatus | null>(null);
const [whatsappLoading, setWhatsappLoading] = useState(true);
useEffect(() => {
if (!user || !accountId) return;
let cancelled = false;
const supabase = createClient();
const userId = user.id;
const acctId = accountId;
// Cheap counts — resolve fast, render immediately.
(async () => {
setCountsLoading(true);
const [membersRes, invitesRes, templatesTotal, templatesPending, tagsRes, fieldsRes] =
await Promise.allSettled([
fetch('/api/account/members', { cache: 'no-store' }).then((r) => r.json()),
canManageMembers
? fetch('/api/account/invitations', { cache: 'no-store' }).then((r) =>
r.json(),
)
: Promise.resolve(null),
supabase
.from('message_templates')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId),
supabase
.from('message_templates')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId)
.eq('status', 'PENDING'),
supabase
.from('tags')
.select('id', { count: 'exact', head: true })
.eq('user_id', userId),
supabase.from('custom_fields').select('id', { count: 'exact', head: true }),
]);
if (cancelled) return;
const members =
membersRes.status === 'fulfilled' && Array.isArray(membersRes.value?.members)
? membersRes.value.members.length
: null;
const pendingInvites =
invitesRes.status === 'fulfilled' &&
invitesRes.value &&
Array.isArray(invitesRes.value.invitations)
? invitesRes.value.invitations.length
: null;
setCounts({
members,
pendingInvites,
templates:
templatesTotal.status === 'fulfilled'
? templatesTotal.value.count ?? null
: null,
templatesPending:
templatesPending.status === 'fulfilled'
? templatesPending.value.count ?? null
: null,
tags: tagsRes.status === 'fulfilled' ? tagsRes.value.count ?? null : null,
customFields:
fieldsRes.status === 'fulfilled' ? fieldsRes.value.count ?? null : null,
});
setCountsLoading(false);
})();
// WhatsApp connection status — slower, independent.
(async () => {
setWhatsappLoading(true);
const [row, health] = await Promise.allSettled([
supabase
.from('whatsapp_config')
.select('phone_number_id')
.eq('account_id', acctId)
.maybeSingle(),
fetch('/api/whatsapp/config', { cache: 'no-store' }).then((r) => r.json()),
]);
if (cancelled) return;
setWhatsapp({
configured: row.status === 'fulfilled' && !!row.value.data?.phone_number_id,
connected: health.status === 'fulfilled' && !!health.value?.connected,
});
setWhatsappLoading(false);
})();
return () => {
cancelled = true;
};
}, [user, accountId, canManageMembers]);
const displayName = profile?.full_name || profile?.email || 'Your account';
const initial = (profile?.full_name || profile?.email || 'U').charAt(0).toUpperCase();
const roleMeta = accountRole ? ROLE_META[accountRole] : null;
const RoleIcon = roleMeta?.icon;
const currencyLabel =
CURRENCIES.find((c) => c.code === defaultCurrency)?.label ?? defaultCurrency;
const themeName = THEMES.find((t) => t.id === theme)?.name ?? theme;
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
// Per-tile loading + subtitle. `null` counts render as a graceful
// fallback so a single failed query never blanks a tile.
const tiles: {
section: SettingsSection;
loading: boolean;
subtitle: ReactNode;
}[] = [
{
section: 'whatsapp',
loading: whatsappLoading,
subtitle: !whatsapp?.configured ? (
'Not set up yet'
) : whatsapp.connected ? (
<>
<StatusDot tone="ok" /> Connected
</>
) : (
<>
<StatusDot tone="muted" /> Needs reconnecting
</>
),
},
{
section: 'members',
loading: countsLoading,
subtitle:
counts?.members == null
? 'View team members'
: `${counts.members} member${counts.members === 1 ? '' : 's'}${
counts.pendingInvites
? ` · ${counts.pendingInvites} pending invite${
counts.pendingInvites === 1 ? '' : 's'
}`
: ''
}`,
},
{
section: 'templates',
loading: countsLoading,
subtitle:
counts?.templates == null
? 'Manage message templates'
: `${counts.templates} template${counts.templates === 1 ? '' : 's'}${
counts.templatesPending
? ` · ${counts.templatesPending} pending review`
: ''
}`,
},
{
section: 'deals',
loading: false,
subtitle: `${defaultCurrency} — ${currencyLabel}`,
},
{
section: 'fields',
loading: countsLoading,
subtitle:
counts?.tags == null && counts?.customFields == null
? 'Tags and custom fields'
: `${counts?.tags ?? 0} tag${counts?.tags === 1 ? '' : 's'} · ${
counts?.customFields ?? 0
} custom field${counts?.customFields === 1 ? '' : 's'}`,
},
{
section: 'appearance',
loading: false,
subtitle: `${cap(mode)} mode · ${themeName} accent`,
},
];
return (
<section className="animate-in fade-in-50 duration-200">
{/* Identity */}
<Card className="flex-row items-center gap-4 px-5 py-5">
<Avatar size="lg" className="size-14">
{profile?.avatar_url ? (
<AvatarImage src={profile.avatar_url} alt={displayName} />
) : null}
<AvatarFallback className="bg-primary/10 text-xl text-primary">
{initial}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<div className="truncate text-base font-semibold text-foreground">
{displayName}
</div>
{profile?.email ? (
<div className="truncate text-sm text-muted-foreground">
{profile.email}
</div>
) : null}
</div>
{roleMeta && RoleIcon ? (
<SettingsChip variant={roleMeta.variant}>
<RoleIcon />
{roleMeta.label}
</SettingsChip>
) : null}
</Card>
{/* Status tiles */}
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{tiles.map(({ section, loading, subtitle }) => {
const meta = SECTION_META[section];
const Icon = meta.icon;
return (
<button
key={section}
type="button"
onClick={() => onSelect(section)}
className={cn(
'group flex items-start gap-3.5 rounded-xl border border-border bg-card p-4 text-left transition-colors',
'hover:border-primary-soft-2 hover:bg-card-2',
)}
>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary-soft text-primary">
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-foreground">
{meta.label}
</span>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{loading ? (
<>
<Loader2 className="size-3 animate-spin" /> Loading…
</>
) : (
subtitle
)}
</span>
</span>
<ChevronRight className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
);
})}
</div>
</section>
);
}

View File

@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
/**
* Section header shown at the top of every settings panel — a title,
* a one-line description, and an optional right-aligned action (e.g.
* "New template", "Invite member"). Mirrors the mockup's `.panel-head`.
*/
export function SettingsPanelHead({
title,
description,
action,
className,
}: {
title: string;
description?: ReactNode;
action?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between',
className,
)}
>
<div className="min-w-0">
<h2 className="text-lg font-semibold tracking-tight text-foreground">
{title}
</h2>
{description ? (
<p className="mt-1 max-w-[62ch] text-sm text-muted-foreground">
{description}
</p>
) : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More