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 NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# Supabase service-role key. Bypasses RLS; used only by server-side # Supabase service-role key. Bypasses RLS; used only by server-side
# routes (the webhook and automation engine). Keep this secret — # routes (the webhook, automation engine, and the public API key
# never paste it into client code. # 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 SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
# WhatsApp token encryption (64 hex chars = 32 bytes, AES-256-GCM). # 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. # See docs/automations-and-cron.md.
# AUTOMATION_CRON_SECRET=generate-a-long-random-string # AUTOMATION_CRON_SECRET=generate-a-long-random-string
# Meta App ID (Meta for Developers → App Settings → Basic). Required # Meta App ID (Meta for Developers → App Settings → Basic). Required to
# only for the (planned) Resumable Upload flow that lets users attach # create/edit message templates with an IMAGE header: Meta only accepts a
# local media files as template header samples. Submitting templates # Resumable-Upload media handle (not a plain URL) as the header sample, and
# with a public sample URL works without it. Pair with META_APP_SECRET. # 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 # META_APP_ID=your-meta-app-id
# When "true", POST /api/whatsapp/templates/submit skips the Meta call # When "true", POST /api/whatsapp/templates/submit skips the Meta call

View File

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

View File

@ -7,3 +7,4 @@ next-env.d.ts
package-lock.json package-lock.json
# Generated / vendored # Generated / vendored
supabase/migrations 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 solo owner who never invites anyone sees the same single-user app they
always did. 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 ### Changed
- **Tenancy moves from per-user to per-account.** RLS on every - **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 is account-scoped, so one shared inbox can be staffed by a whole
team. Solo use stays single-user with zero setup. team. Solo use stays single-user with zero setup.
- **Account management** — email, password, avatar, global sign-out. - **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? ## 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/**", "out/**",
"build/**", "build/**",
"next-env.d.ts", "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: "X-Frame-Options", value: "DENY" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { 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", key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()", value: "camera=(), microphone=(self), geolocation=(), payment=(), usb=()",
}, },
{ {
key: "Content-Security-Policy-Report-Only", 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 // https URLs paste-able from the UI), OG images, data URLs for
// tiny inline assets. // tiny inline assets.
"img-src 'self' data: blob: https:", "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:", "font-src 'self' data:",
// Supabase REST + realtime (WSS). All Meta API calls happen // Supabase REST + realtime (WSS). All Meta API calls happen
// server-side, so graph.facebook.com does not belong here. // 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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.4.0", "date-fns": "^4.4.0",
"lucide-react": "^1.17.0", "lucide-react": "^1.18.0",
"next": "16.2.6", "next": "16.2.6",
"opus-recorder": "^8.0.5",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"recharts": "^3.8.1", "recharts": "^3.8.1",
"shadcn": "^4.10.0", "shadcn": "^4.11.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
@ -67,16 +68,18 @@
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.6", "eslint-config-next": "16.2.6",
"prettier": "^3.8.3", "prettier": "^3.8.4",
"prettier-plugin-tailwindcss": "^0.8.0", "prettier-plugin-tailwindcss": "^0.8.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^6", "typescript": "^6",
"vitest": "^4.1.8" "vitest": "^4.1.9"
}, },
"overrides": { "overrides": {
"postcss": "^8.5.10", "postcss": "^8.5.10",
"ip-address": "^10.1.1", "ip-address": "^10.1.1",
"fast-uri": "^3.1.2", "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) { if (success) {
return ( 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">
<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"> <CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10"> <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" /> <CheckCircle className="h-6 w-6 text-primary" />
</div> </div>
<CardTitle className="text-xl text-white"> <CardTitle className="text-xl text-foreground">
Check your email Check your email
</CardTitle> </CardTitle>
<CardDescription className="text-slate-400"> <CardDescription className="text-muted-foreground">
We&apos;ve sent a password reset link to{" "} 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. inbox.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@ -62,7 +62,7 @@ export default function ForgotPasswordPage() {
<Link href="/login"> <Link href="/login">
<Button <Button
variant="outline" 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 Back to sign in
</Button> </Button>
@ -74,14 +74,14 @@ export default function ForgotPasswordPage() {
} }
return ( 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">
<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"> <CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10"> <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" /> <MessageSquare className="h-6 w-6 text-primary" />
</div> </div>
<CardTitle className="text-xl text-white">Reset password</CardTitle> <CardTitle className="text-xl text-foreground">Reset password</CardTitle>
<CardDescription className="text-slate-400"> <CardDescription className="text-muted-foreground">
Enter your email and we&apos;ll send you a reset link Enter your email and we&apos;ll send you a reset link
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@ -94,7 +94,7 @@ export default function ForgotPasswordPage() {
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-slate-300"> <Label htmlFor="email" className="text-muted-foreground">
Email Email
</Label> </Label>
<Input <Input
@ -104,7 +104,7 @@ export default function ForgotPasswordPage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required 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>
@ -119,7 +119,7 @@ export default function ForgotPasswordPage() {
<Link <Link
href="/login" 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" /> <ArrowLeft className="h-4 w-4" />
Back to sign in Back to sign in

View File

@ -67,8 +67,8 @@ function LoginPageInner() {
}; };
return ( 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">
<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"> <CardHeader className="items-center text-center">
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10"> <div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
{inviteToken ? ( {inviteToken ? (
@ -77,10 +77,10 @@ function LoginPageInner() {
<MessageSquare className="h-6 w-6 text-primary" /> <MessageSquare className="h-6 w-6 text-primary" />
)} )}
</div> </div>
<CardTitle className="text-xl text-white"> <CardTitle className="text-xl text-foreground">
{inviteToken ? "Sign in to accept" : "Welcome back"} {inviteToken ? "Sign in to accept" : "Welcome back"}
</CardTitle> </CardTitle>
<CardDescription className="text-slate-400"> <CardDescription className="text-muted-foreground">
{inviteToken {inviteToken
? "Sign in and we'll take you to the invitation." ? "Sign in and we'll take you to the invitation."
: "Sign in to your account"} : "Sign in to your account"}
@ -95,7 +95,7 @@ function LoginPageInner() {
)} )}
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label htmlFor="email" className="text-slate-300"> <Label htmlFor="email" className="text-muted-foreground">
Email Email
</Label> </Label>
<Input <Input
@ -105,13 +105,13 @@ function LoginPageInner() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
required 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>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label htmlFor="password" className="text-slate-300"> <Label htmlFor="password" className="text-muted-foreground">
Password Password
</Label> </Label>
<Link <Link
@ -128,7 +128,7 @@ function LoginPageInner() {
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
required 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>
@ -141,7 +141,7 @@ function LoginPageInner() {
</Button> </Button>
</form> </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?{" "} Don&apos;t have an account?{" "}
<Link <Link
href={ href={

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { Contact, Tag, ContactTag } from '@/types'; import type { Contact, Tag, ContactTag } from '@/types';
@ -29,6 +29,11 @@ import {
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { import {
Search, Search,
Plus, Plus,
@ -41,6 +46,8 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
SlidersHorizontal, SlidersHorizontal,
Filter,
X,
} from 'lucide-react'; } from 'lucide-react';
import { ContactForm } from '@/components/contacts/contact-form'; import { ContactForm } from '@/components/contacts/contact-form';
import { ContactDetailView } from '@/components/contacts/contact-detail-view'; 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 { CustomFieldsManager } from '@/components/contacts/custom-fields-manager';
import { useCan } from '@/hooks/use-can'; import { useCan } from '@/hooks/use-can';
import { GatedButton } from '@/components/ui/gated-button'; import { GatedButton } from '@/components/ui/gated-button';
import { Checkbox } from '@/components/ui/checkbox';
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@ -65,6 +73,8 @@ export default function ContactsPage() {
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [totalCount, setTotalCount] = useState(0); const [totalCount, setTotalCount] = useState(0);
// Tag filter — contacts shown must have ANY of these tags (OR).
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
// Modals // Modals
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
@ -78,57 +88,107 @@ export default function ContactsPage() {
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null); const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
const [deleting, setDeleting] = useState(false); 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 // All tags for display
const [tagsMap, setTagsMap] = useState<Record<string, Tag>>({}); 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 fetchTags = useCallback(async () => {
const { data } = await supabase.from('tags').select('*'); const { data } = await supabase.from('tags').select('*');
if (data) { if (data) {
const map: Record<string, Tag> = {}; const map: Record<string, Tag> = {};
data.forEach((t) => (map[t.id] = t)); data.forEach((t) => (map[t.id] = t));
setTagsMap(map); 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]); }, [supabase]);
const fetchContacts = useCallback(async () => { const fetchContacts = useCallback(async () => {
const seq = ++fetchSeq.current;
setLoading(true); 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 from = page * PAGE_SIZE;
const to = from + PAGE_SIZE - 1; 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 let query = supabase
.from('contacts') .from('contacts')
.select('*', { count: 'exact' }) .select('*', { count: 'exact' })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.range(from, to); .range(from, to);
if (search.trim()) { if (term) {
const term = `%${search.trim()}%`; const like = `%${term}%`;
query = query.or(`name.ilike.${term},phone.ilike.${term},email.ilike.${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) { if (error) {
toast.error('Failed to load contacts'); toast.error('Failed to load contacts');
setLoading(false); setLoading(false);
return; return;
} }
contactRows = data ?? [];
count = exactCount ?? 0;
}
setTotalCount(count ?? 0); setTotalCount(count);
if (!data || data.length === 0) { if (contactRows.length === 0) {
setContacts([]); setContacts([]);
setLoading(false); setLoading(false);
return; return;
} }
// Fetch tags for these contacts // Fetch tags for these contacts
const contactIds = data.map((c) => c.id); const contactIds = contactRows.map((c) => c.id);
const { data: contactTags } = await supabase const { data: contactTags } = await supabase
.from('contact_tags') .from('contact_tags')
.select('contact_id, tag_id') .select('contact_id, tag_id')
.in('contact_id', contactIds); .in('contact_id', contactIds);
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
const tagsByContact: Record<string, string[]> = {}; const tagsByContact: Record<string, string[]> = {};
contactTags?.forEach((ct) => { contactTags?.forEach((ct) => {
@ -136,7 +196,7 @@ export default function ContactsPage() {
tagsByContact[ct.contact_id].push(ct.tag_id); tagsByContact[ct.contact_id].push(ct.tag_id);
}); });
const enriched: ContactWithTags[] = data.map((c) => ({ const enriched: ContactWithTags[] = contactRows.map((c) => ({
...c, ...c,
tags: (tagsByContact[c.id] ?? []) tags: (tagsByContact[c.id] ?? [])
.map((tid) => tagsMap[tid]) .map((tid) => tagsMap[tid])
@ -145,7 +205,7 @@ export default function ContactsPage() {
setContacts(enriched); setContacts(enriched);
setLoading(false); setLoading(false);
}, [supabase, page, search, tagsMap]); }, [supabase, page, search, selectedTagIds, tagsMap]);
// Load-once-on-mount-ish data fetches. Each setter inside runs // Load-once-on-mount-ish data fetches. Each setter inside runs
// inside an async promise completion (Supabase await), not // inside an async promise completion (Supabase await), not
@ -208,17 +268,82 @@ export default function ContactsPage() {
setDeleteTarget(null); 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 totalPages = Math.ceil(totalCount / PAGE_SIZE);
const hasNext = page < totalPages - 1; const hasNext = page < totalPages - 1;
const hasPrev = page > 0; 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-white">Contacts</h1> <h1 className="text-2xl font-bold text-foreground">Contacts</h1>
<p className="text-sm text-slate-400 mt-1"> <p className="text-sm text-muted-foreground mt-1">
Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`} Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`}
</p> </p>
</div> </div>
@ -227,7 +352,7 @@ export default function ContactsPage() {
<Button <Button
variant="outline" variant="outline"
onClick={() => setCustomFieldsOpen(true)} 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" /> <SlidersHorizontal className="size-4" />
Custom fields Custom fields
@ -238,7 +363,7 @@ export default function ContactsPage() {
canAct={canEdit} canAct={canEdit}
gateReason="add or import contacts" gateReason="add or import contacts"
onClick={() => setImportOpen(true)} 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" /> <Upload className="size-4" />
Import Import
@ -255,9 +380,11 @@ export default function ContactsPage() {
</div> </div>
</div> </div>
{/* Search */} {/* Search + tag filter */}
<div className="relative max-w-sm"> <div className="space-y-2">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-slate-500" /> <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 <Input
value={search} value={search}
onChange={(e) => { onChange={(e) => {
@ -267,48 +394,187 @@ export default function ContactsPage() {
setPage(0); setPage(0);
}} }}
placeholder="Search by name, phone, or email..." 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> </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 */} {/* Table */}
<div className="rounded-lg border border-slate-800 overflow-hidden"> <div className="rounded-lg border border-border overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent"> <TableRow className="border-border hover:bg-transparent">
<TableHead className="text-slate-400">Name</TableHead> <TableHead className="w-10">
<TableHead className="text-slate-400">Phone</TableHead> <Checkbox
<TableHead className="text-slate-400 hidden md:table-cell">Email</TableHead> checked={allOnPageSelected}
<TableHead className="text-slate-400 hidden lg:table-cell">Company</TableHead> indeterminate={!allOnPageSelected && someOnPageSelected}
<TableHead className="text-slate-400 hidden md:table-cell">Tags</TableHead> onCheckedChange={toggleSelectAll}
<TableHead className="text-slate-400 hidden lg:table-cell">Created</TableHead> disabled={contacts.length === 0}
<TableHead className="text-slate-400 w-12" /> 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> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow className="border-slate-800"> <TableRow className="border-border">
<TableCell colSpan={7} className="text-center py-12"> <TableCell colSpan={8} className="text-center py-12">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Loader2 className="size-6 animate-spin text-primary" /> <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> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : contacts.length === 0 ? ( ) : contacts.length === 0 ? (
<TableRow className="border-slate-800"> <TableRow className="border-border">
<TableCell colSpan={7} className="text-center py-12"> <TableCell colSpan={8} className="text-center py-12">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<Users className="size-8 text-slate-600" /> <Users className="size-8 text-muted-foreground" />
<p className="text-sm text-slate-500"> <p className="text-sm text-muted-foreground">
{search ? 'No contacts match your search.' : 'No contacts yet.'} {hasActiveFilters
? 'No contacts match your filters.'
: 'No contacts yet.'}
</p> </p>
{!search && ( {!hasActiveFilters && (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={openAddForm} 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" /> <Plus className="size-3.5" />
Add your first contact Add your first contact
@ -321,20 +587,27 @@ export default function ContactsPage() {
contacts.map((contact) => ( contacts.map((contact) => (
<TableRow <TableRow
key={contact.id} 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)} onClick={() => openDetail(contact.id)}
> >
<TableCell className="text-white font-medium"> <TableCell onClick={(e) => e.stopPropagation()}>
{contact.name || <span className="text-slate-500 italic">Unnamed</span>} <Checkbox
checked={selected.has(contact.id)}
onCheckedChange={() => toggleSelect(contact.id)}
aria-label={`Select ${contact.name || contact.phone}`}
/>
</TableCell> </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} {contact.phone}
</TableCell> </TableCell>
<TableCell className="text-slate-400 hidden md:table-cell text-sm"> <TableCell className="text-muted-foreground hidden md:table-cell text-sm">
{contact.email || <span className="text-slate-600">-</span>} {contact.email || <span className="text-muted-foreground">-</span>}
</TableCell> </TableCell>
<TableCell className="text-slate-400 hidden lg:table-cell text-sm"> <TableCell className="text-muted-foreground hidden lg:table-cell text-sm">
{contact.company || <span className="text-slate-600">-</span>} {contact.company || <span className="text-muted-foreground">-</span>}
</TableCell> </TableCell>
<TableCell className="hidden md:table-cell"> <TableCell className="hidden md:table-cell">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
@ -352,16 +625,16 @@ export default function ContactsPage() {
</span> </span>
)) ))
) : ( ) : (
<span className="text-slate-600 text-xs">-</span> <span className="text-muted-foreground text-xs">-</span>
)} )}
{contact.tags && contact.tags.length > 3 && ( {contact.tags && contact.tags.length > 3 && (
<span className="text-[10px] text-slate-500"> <span className="text-[10px] text-muted-foreground">
+{contact.tags.length - 3} +{contact.tags.length - 3}
</span> </span>
)} )}
</div> </div>
</TableCell> </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', { {new Date(contact.created_at).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@ -375,7 +648,7 @@ export default function ContactsPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="text-slate-400 hover:text-white" className="text-muted-foreground hover:text-foreground"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
} }
@ -384,19 +657,19 @@ export default function ContactsPage() {
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
className="bg-slate-900 border-slate-700" className="bg-popover border-border"
> >
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
openEditForm(contact); 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" /> <Pencil className="size-4" />
Edit Edit
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
variant="destructive" variant="destructive"
onClick={(e) => { onClick={(e) => {
@ -420,7 +693,7 @@ export default function ContactsPage() {
{/* Pagination */} {/* Pagination */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="flex items-center justify-between"> <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{' '} Showing {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalCount)} of{' '}
{totalCount} {totalCount}
</p> </p>
@ -430,11 +703,11 @@ export default function ContactsPage() {
size="icon-sm" size="icon-sm"
disabled={!hasPrev} disabled={!hasPrev}
onClick={() => setPage((p) => p - 1)} 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" /> <ChevronLeft className="size-4" />
</Button> </Button>
<span className="text-xs text-slate-400 px-2"> <span className="text-xs text-muted-foreground px-2">
Page {page + 1} of {totalPages} Page {page + 1} of {totalPages}
</span> </span>
<Button <Button
@ -442,7 +715,7 @@ export default function ContactsPage() {
size="icon-sm" size="icon-sm"
disabled={!hasNext} disabled={!hasNext}
onClick={() => setPage((p) => p + 1)} 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" /> <ChevronRight className="size-4" />
</Button> </Button>
@ -491,22 +764,22 @@ export default function ContactsPage() {
{/* Delete Confirmation */} {/* Delete Confirmation */}
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}> <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> <DialogHeader>
<DialogTitle className="text-white">Delete Contact</DialogTitle> <DialogTitle className="text-popover-foreground">Delete Contact</DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-muted-foreground">
Are you sure you want to delete{' '} 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} {deleteTarget?.name || deleteTarget?.phone}
</span> </span>
? This action cannot be undone. ? This action cannot be undone.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter className="bg-slate-900 border-slate-700"> <DialogFooter className="bg-popover border-border">
<Button <Button
variant="outline" variant="outline"
onClick={() => setDeleteConfirmOpen(false)} onClick={() => setDeleteConfirmOpen(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel Cancel
</Button> </Button>
@ -521,6 +794,41 @@ export default function ContactsPage() {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
); );
} }

View File

@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { AuthProvider, useAuth } from "@/hooks/use-auth"; import { AuthProvider, useAuth } from "@/hooks/use-auth";
import { Sidebar } from "@/components/layout/sidebar"; import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header"; import { Header } from "@/components/layout/header";
import { PresenceHeartbeat } from "@/components/presence/presence-heartbeat";
// Auth-gated dashboard shell. Extracted from the layout so the layout // Auth-gated dashboard shell. Extracted from the layout so the layout
// itself can stay a server component and export metadata (noindex) — // itself can stay a server component and export metadata (noindex) —
@ -27,10 +28,10 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
if (loading) { if (loading) {
return ( 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="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" /> <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>
</div> </div>
); );
@ -39,7 +40,10 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
if (!user) return null; if (!user) return null;
return ( 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} /> <Sidebar open={sidebarOpen} onClose={closeSidebar} />
<div className="flex flex-1 flex-col overflow-hidden"> <div className="flex flex-1 flex-col overflow-hidden">
<Header onOpenSidebar={() => setSidebarOpen(true)} /> <Header onOpenSidebar={() => setSidebarOpen(true)} />

View File

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

View File

@ -65,14 +65,14 @@ export default function FlowEditorPage() {
if (loading) { if (loading) {
return ( return (
<div className="flex h-full items-center justify-center"> <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> </div>
); );
} }
if (notFound || !flow) { if (notFound || !flow) {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-3"> <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 <button
type="button" type="button"
onClick={() => router.push("/flows")} onClick={() => router.push("/flows")}

View File

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

View File

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

View File

@ -12,6 +12,10 @@ import { toast } from "sonner";
import { WifiOff } from "lucide-react"; import { WifiOff } from "lucide-react";
import { cn } from "@/lib/utils"; 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() { export default function InboxPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
@ -39,6 +43,36 @@ export default function InboxPage() {
*/ */
const [resyncToken, setResyncToken] = useState(0); 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 // Fire the deep-link auto-select exactly once per URL — subsequent
// list refreshes (realtime, manual refetch) must not snap the user // list refreshes (realtime, manual refetch) must not snap the user
// back to the deep-linked conversation if they've already clicked // back to the deep-linked conversation if they've already clicked
@ -570,13 +604,20 @@ export default function InboxPage() {
onBack={handleCloseConversation} onBack={handleCloseConversation}
resyncToken={resyncToken} resyncToken={resyncToken}
onRefresh={handleManualRefresh} onRefresh={handleManualRefresh}
contactPanelOpen={contactPanelOpen}
onToggleContactPanel={handleToggleContactPanel}
/> />
</div> </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"> <div className="hidden lg:block">
<ContactSidebar contact={activeContact} /> <ContactSidebar contact={activeContact} />
</div> </div>
)}
</div> </div>
</div> </div>
); );

View File

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

View File

@ -1,179 +1,84 @@
'use client'; 'use client';
import { useMemo, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import {
Settings, import { useAuth } from '@/hooks/use-auth';
MessageSquare, import { useTheme } from '@/hooks/use-theme';
Tag, import { SettingsRail } from '@/components/settings/settings-rail';
User, import { SettingsOverview } from '@/components/settings/settings-overview';
Palette, import { ProfileForm } from '@/components/settings/profile-form';
UsersRound, import { SecurityPanel } from '@/components/settings/security-panel';
Coins, import { AppearancePanel } from '@/components/settings/appearance-panel';
SlidersHorizontal,
} from 'lucide-react';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { useCan } from '@/hooks/use-can';
import { WhatsAppConfig } from '@/components/settings/whatsapp-config'; import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
import { TemplateManager } from '@/components/settings/template-manager'; import { TemplateManager } from '@/components/settings/template-manager';
import { TagManager } from '@/components/settings/tag-manager'; import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel';
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 { DealsSettings } from '@/components/settings/deals-settings'; import { DealsSettings } from '@/components/settings/deals-settings';
import { CustomFieldsSettings } from '@/components/settings/custom-fields-settings'; import { MembersTab } from '@/components/settings/members-tab';
import { ApiKeysSettings } from '@/components/settings/api-keys-settings';
const TAB_VALUES = [ import {
'profile', resolveSection,
'whatsapp', type SettingsSection,
'templates', } from '@/components/settings/settings-sections';
'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);
}
export default function SettingsPage() { export default function SettingsPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { defaultCurrency } = useAuth();
const { mode } = useTheme();
// Custom-field definitions are account-wide config, so editing them is // The URL (`?tab=`) is the single source of truth for the active
// admin+ only — mirror the gate on the Contacts page. The `custom_fields` // section — deep-linkable, and it keeps the existing links in the
// RLS rejects non-admin writes regardless. // app sidebar/header working. Legacy tab values (tags, custom-fields)
const canEditSettings = useCan('edit-settings'); // 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 const go = (next: SettingsSection) => {
// 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 params = new URLSearchParams(searchParams.toString()); const params = new URLSearchParams(searchParams.toString());
params.set('tab', next); params.set('tab', next);
router.replace(`/settings?${params.toString()}`, { scroll: false }); 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 ( return (
<div className="space-y-6">
<div> <div>
<h1 className="text-2xl font-bold text-white">Settings</h1> <div>
<p className="mt-1 text-sm text-slate-400"> <h1 className="text-2xl font-bold tracking-tight text-foreground">
Manage your profile, WhatsApp® integration, message templates, and Settings
tags. </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> </p>
</div> </div>
<Tabs value={tab} onValueChange={(v) => onChange(v as TabValue)}> <div className="mt-6 grid gap-6 lg:grid-cols-[236px_minmax(0,1fr)] lg:items-start">
<TabsList className="border border-slate-700 bg-slate-900"> <SettingsRail active={section} onSelect={go} hints={hints} />
<TabsTrigger <div className="min-w-0">{panel[section]}</div>
value="profile" </div>
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> </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 { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/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 { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
import { supabaseAdmin } from '@/lib/flows/admin-client' import { supabaseAdmin } from '@/lib/flows/admin-client'
import { import {
@ -59,10 +64,15 @@ export async function POST(request: Request) {
const body = await request.json() const body = await request.json()
const { 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, message_type,
content_text, content_text,
media_url, media_url,
filename,
template_name, template_name,
template_language, template_language,
template_params, template_params,
@ -70,9 +80,27 @@ export async function POST(request: Request) {
reply_to_message_id, reply_to_message_id,
} = body } = body
if (!conversation_id || !message_type) { if ((!conversationIdInput && !contact_id) || !message_type) {
return NextResponse.json( 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 } { status: 400 }
) )
} }
@ -91,20 +119,89 @@ export async function POST(request: Request) {
) )
} }
// Fetch conversation and contact if (isMediaKind && !media_url) {
const { data: conversation, error: convError } = await supabase 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') .from('conversations')
.select('*, contact:contacts(*)') .select('*, contact:contacts(*)')
.eq('id', conversation_id) .eq('id', conversationIdInput)
.eq('account_id', accountId) .eq('account_id', accountId)
.single() .single()
if (convError || !conversation) { if (convError || !data) {
return NextResponse.json( return NextResponse.json(
{ error: 'Conversation not found' }, { error: 'Conversation not found' },
{ status: 404 } { 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 const contact = conversation.contact
if (!contact?.phone) { if (!contact?.phone) {
@ -246,6 +343,22 @@ export async function POST(request: Request) {
}) })
return result.messageId 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({ const result = await sendTextMessage({
phoneNumberId: config.phone_number_id, phoneNumberId: config.phone_number_id,
accessToken, 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, type TemplatePayload,
} from '@/lib/whatsapp/template-validators' } from '@/lib/whatsapp/template-validators'
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components' import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
/** /**
* Per-template lifecycle endpoint. * Per-template lifecycle endpoint.
@ -136,8 +137,6 @@ export async function PATCH(
) )
} }
const metaPayload = buildMetaTemplatePayload(payload)
if (!isDryRun()) { if (!isDryRun()) {
const { data: config, error: configError } = await supabase const { data: config, error: configError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
@ -151,6 +150,19 @@ export async function PATCH(
) )
} }
const accessToken = decrypt(config.access_token) 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 { try {
await editMessageTemplate({ await editMessageTemplate({
metaTemplateId: existing.meta_template_id, metaTemplateId: existing.meta_template_id,

View File

@ -8,6 +8,7 @@ import {
type TemplatePayload, type TemplatePayload,
} from '@/lib/whatsapp/template-validators' } from '@/lib/whatsapp/template-validators'
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components' import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize' import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
/** /**
@ -137,8 +138,6 @@ export async function POST(request: Request) {
) )
} }
const metaPayload = buildMetaTemplatePayload(payload)
const dryRun = const dryRun =
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' || process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1' process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
@ -175,6 +174,21 @@ export async function POST(request: Request) {
} }
const accessToken = decrypt(config.access_token) 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 { try {
const meta = await submitMessageTemplate({ const meta = await submitMessageTemplate({
wabaId: config.waba_id, 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 { createClient } from '@supabase/supabase-js'
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption' import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api' import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
@ -12,6 +12,12 @@ import {
isTemplateWebhookField, isTemplateWebhookField,
} from '@/lib/whatsapp/template-webhook' } 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 // Lazy-initialized to avoid build-time crash when env vars are missing
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let _adminClient: any = null let _adminClient: any = null
@ -182,9 +188,26 @@ export async function POST(request: Request) {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }) return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
} }
// Process asynchronously so we can ack Meta within their timeout. // Process AFTER the response so we ack Meta within their ~20s timeout
processWebhook(body).catch((error) => { // (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) console.error('Error processing webhook:', error)
}
}) })
return NextResponse.json({ status: 'received' }, { status: 200 }) return NextResponse.json({ status: 'received' }, { status: 200 })

View File

@ -39,6 +39,15 @@
--color-popover: var(--popover); --color-popover: var(--popover);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
--color-card: var(--card); --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-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8); --radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius); --radius-lg: var(--radius);
@ -49,39 +58,43 @@
} }
/* ============================================================ /* ============================================================
* COLOR THEMES * THEMING — two orthogonal dimensions
* *
* 5 dark themes sharing structure, swapping only the primary * 1. MODE (light / dark) → neutral surfaces. Selected with
* accent (and a tiny hue rotation on the slate base). Switched * `document.documentElement.dataset.mode`. Defaults to dark.
* at runtime by setting `document.documentElement.dataset.theme` * 2. ACCENT (violet / …) → the primary color only. Selected
* — see ThemeProvider + the inline boot script in layout.tsx. * with `document.documentElement.dataset.theme`.
* *
* `:root` mirrors Violet so default visitors render correctly * The two compose: any accent works in either mode. Neutral
* before JS runs. `[data-theme="violet"]` is included for the * tokens (background / card / border / muted / sidebar surfaces /
* explicit-select path (settings UI sets it after a pick). * 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 * `:root` carries the dark-mode + violet defaults so a visitor
* new — used for hovered primary buttons and tinted-primary * renders correctly before JS runs; the boot script in
* surfaces (sidebar active pill, validation badges). Components * layout.tsx replays the saved mode + accent before first paint.
* that don't use them remain backward-compatible. *
* 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 * Token shapes come straight from the design handoff at
* project/Color Themes.html — change one, change the other. * project/Color Themes.html — change one, change the other.
* ============================================================ */ * ============================================================ */
/* ---- MODE: neutral surfaces ---- */
:root, :root,
html[data-theme="violet"] { html[data-mode="dark"] {
--background: oklch(0.13 0.01 260); --background: oklch(0.13 0.01 260);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.18 0.01 260); --card: oklch(0.18 0.01 260);
--card-2: oklch(0.205 0.01 260);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
--popover: oklch(0.18 0.01 260); --popover: oklch(0.18 0.01 260);
--popover-foreground: oklch(0.985 0 0); --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: oklch(0.22 0.01 260);
--secondary-foreground: oklch(0.985 0 0); --secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.22 0.01 260); --muted: oklch(0.22 0.01 260);
@ -91,8 +104,6 @@ html[data-theme="violet"] {
--destructive: oklch(0.577 0.245 27.325); --destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.28 0.01 260); --border: oklch(0.28 0.01 260);
--input: 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-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0); --chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0); --chart-4: oklch(0.371 0 0);
@ -100,159 +111,109 @@ html[data-theme="violet"] {
--radius: 0.625rem; --radius: 0.625rem;
--sidebar: oklch(0.16 0.01 260); --sidebar: oklch(0.16 0.01 260);
--sidebar-foreground: oklch(0.985 0 0); --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: oklch(0.22 0.01 260);
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(0.28 0.01 260); --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); --sidebar-ring: oklch(0.526 0.247 293);
} }
html[data-theme="emerald"] { 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: oklch(0.62 0.16 162);
--primary-foreground: oklch(0.16 0.02 162); --primary-foreground: oklch(0.16 0.02 162);
--primary-hover: oklch(0.68 0.15 162); --primary-hover: oklch(0.68 0.15 162);
--primary-soft: oklch(0.62 0.16 162 / 0.12); --primary-soft: oklch(0.62 0.16 162 / 0.12);
--primary-soft-2: oklch(0.62 0.16 162 / 0.22); --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); --ring: oklch(0.62 0.16 162);
--chart-1: oklch(0.62 0.16 162); --chart-1: oklch(0.62 0.16 162);
--chart-2: oklch(0.7 0.14 195); --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: oklch(0.62 0.16 162);
--sidebar-primary-foreground: oklch(0.16 0.02 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); --sidebar-ring: oklch(0.62 0.16 162);
} }
html[data-theme="cobalt"] { 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: oklch(0.585 0.2 254);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.66 0.18 254); --primary-hover: oklch(0.66 0.18 254);
--primary-soft: oklch(0.585 0.2 254 / 0.12); --primary-soft: oklch(0.585 0.2 254 / 0.12);
--primary-soft-2: oklch(0.585 0.2 254 / 0.22); --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); --ring: oklch(0.585 0.2 254);
--chart-1: oklch(0.585 0.2 254); --chart-1: oklch(0.585 0.2 254);
--chart-2: oklch(0.7 0.15 220); --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: oklch(0.585 0.2 254);
--sidebar-primary-foreground: oklch(0.985 0 0); --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); --sidebar-ring: oklch(0.585 0.2 254);
} }
html[data-theme="amber"] { 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: oklch(0.745 0.16 65);
--primary-foreground: oklch(0.18 0.03 65); --primary-foreground: oklch(0.18 0.03 65);
--primary-hover: oklch(0.8 0.15 65); --primary-hover: oklch(0.8 0.15 65);
--primary-soft: oklch(0.745 0.16 65 / 0.12); --primary-soft: oklch(0.745 0.16 65 / 0.12);
--primary-soft-2: oklch(0.745 0.16 65 / 0.22); --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); --ring: oklch(0.745 0.16 65);
--chart-1: oklch(0.745 0.16 65); --chart-1: oklch(0.745 0.16 65);
--chart-2: oklch(0.7 0.15 35); --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: oklch(0.745 0.16 65);
--sidebar-primary-foreground: oklch(0.18 0.03 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); --sidebar-ring: oklch(0.745 0.16 65);
} }
html[data-theme="rose"] { 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: oklch(0.645 0.22 16);
--primary-foreground: oklch(0.985 0 0); --primary-foreground: oklch(0.985 0 0);
--primary-hover: oklch(0.71 0.2 16); --primary-hover: oklch(0.71 0.2 16);
--primary-soft: oklch(0.645 0.22 16 / 0.12); --primary-soft: oklch(0.645 0.22 16 / 0.12);
--primary-soft-2: oklch(0.645 0.22 16 / 0.22); --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); --ring: oklch(0.645 0.22 16);
--chart-1: oklch(0.645 0.22 16); --chart-1: oklch(0.645 0.22 16);
--chart-2: oklch(0.7 0.18 340); --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: oklch(0.645 0.22 16);
--sidebar-primary-foreground: oklch(0.985 0 0); --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); --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) ----- // ----- Loading state (peek pending OR auth not yet resolved) -----
if (peek === null || authedUserId === undefined) { if (peek === null || authedUserId === undefined) {
return ( 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"> <CardContent className="flex flex-col items-center gap-3 py-12">
<Loader2 className="size-6 animate-spin text-primary" /> <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> </CardContent>
</Card> </Card>
); );
@ -233,13 +233,13 @@ export default function JoinPage() {
if (!peek.ok) { if (!peek.ok) {
const copy = FAIL_COPY[peek.reason]; const copy = FAIL_COPY[peek.reason];
return ( 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"> <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"> <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" /> <MailX className="h-6 w-6 text-red-400" />
</div> </div>
<CardTitle className="text-xl text-white">{copy.title}</CardTitle> <CardTitle className="text-xl text-foreground">{copy.title}</CardTitle>
<CardDescription className="text-slate-400"> <CardDescription className="text-muted-foreground">
{copy.body} {copy.body}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@ -262,7 +262,7 @@ export default function JoinPage() {
<Link href="/signup"> <Link href="/signup">
<Button <Button
variant="outline" 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 Create a new account instead
</Button> </Button>
@ -278,7 +278,7 @@ export default function JoinPage() {
<Link href="/login"> <Link href="/login">
<Button <Button
variant="outline" 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 Sign in
</Button> </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"> <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" /> <UsersRound className="h-6 w-6 text-primary" />
</div> </div>
<CardTitle className="text-xl text-white"> <CardTitle className="text-xl text-foreground">
You&apos;re invited to{' '} You&apos;re invited to{' '}
<span className="text-primary">{peek.account_name}</span> <span className="text-primary">{peek.account_name}</span>
</CardTitle> </CardTitle>
<CardDescription className="text-slate-400"> <CardDescription className="text-muted-foreground">
You&apos;ll join as{' '} 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" /> <ShieldCheck className="size-3.5 text-primary" />
{ROLE_LABEL[peek.role]} {ROLE_LABEL[peek.role]}
</span> </span>
@ -321,7 +321,7 @@ export default function JoinPage() {
if (authedUserId) { if (authedUserId) {
return ( 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} {inviteHeader}
<CardContent className="flex flex-col gap-3"> <CardContent className="flex flex-col gap-3">
<Button <Button
@ -341,9 +341,9 @@ export default function JoinPage() {
</> </>
)} )}
</Button> </Button>
<p className="text-center text-xs text-slate-500"> <p className="text-center text-xs text-muted-foreground">
Accepting moves your login into{' '} 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. empty personal account from signup will be cleaned up.
</p> </p>
</CardContent> </CardContent>
@ -359,30 +359,30 @@ export default function JoinPage() {
if (!open) setConflictMessage(null); 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> <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" /> <AlertTriangle className="size-4 text-amber-400" />
Can&apos;t join {peek.account_name} with this account Can&apos;t join {peek.account_name} with this account
</DialogTitle> </DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-muted-foreground">
{conflictMessage} {conflictMessage}
</DialogDescription> </DialogDescription>
</DialogHeader> </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> <p>
To join{' '} 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. sign out and sign up again with a different email address.
The invite link stays valid as long as it hasn&apos;t The invite link stays valid as long as it hasn&apos;t
expired. expired.
</p> </p>
</div> </div>
<DialogFooter className="bg-slate-900 border-slate-700"> <DialogFooter className="bg-popover border-border">
<Button <Button
variant="outline" variant="outline"
onClick={() => setConflictMessage(null)} 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 Stay signed in
</Button> </Button>
@ -409,7 +409,7 @@ export default function JoinPage() {
// ----- Not authed: prompt to sign up or sign in ----- // ----- Not authed: prompt to sign up or sign in -----
return ( 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} {inviteHeader}
<CardContent className="flex flex-col gap-2"> <CardContent className="flex flex-col gap-2">
<Link href={`/signup?invite=${encodeURIComponent(token!)}`}> <Link href={`/signup?invite=${encodeURIComponent(token!)}`}>
@ -420,7 +420,7 @@ export default function JoinPage() {
<Link href={`/login?invite=${encodeURIComponent(token!)}`}> <Link href={`/login?invite=${encodeURIComponent(token!)}`}>
<Button <Button
variant="outline" 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 I already have an account
</Button> </Button>

View File

@ -37,7 +37,7 @@ export const metadata: Metadata = {
export default function JoinLayout({ children }: { children: ReactNode }) { export default function JoinLayout({ children }: { children: ReactNode }) {
return ( 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} {children}
</div> </div>
); );

View File

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

View File

@ -95,7 +95,7 @@ const STEP_META: Record<AutomationStepType, StepMeta> = {
assign_conversation: { label: "Assign Conversation", icon: UserCheck, border: "border-l-primary" }, assign_conversation: { label: "Assign Conversation", icon: UserCheck, border: "border-l-primary" },
update_contact_field: { label: "Update Contact Field", icon: PencilLine, 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" }, 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" }, condition: { label: "Condition (If/Else)", icon: GitBranch, border: "border-l-amber-500" },
send_webhook: { label: "Send Webhook", icon: Webhook, border: "border-l-primary" }, send_webhook: { label: "Send Webhook", icon: Webhook, border: "border-l-primary" },
close_conversation: { label: "Close Conversation", icon: CircleSlash, 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 = 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 /** Tag dropdown by name + color, storing the tag's id. Falls back to a
* raw id input when no tags exist yet. */ * raw id input when no tags exist yet. */
@ -269,7 +269,7 @@ function TagSelect({
placeholder="Tag id" placeholder="Tag id"
value={value} value={value}
onChange={(e) => onChange(e.target.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 ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <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" }} style={{ backgroundColor: selected?.color ?? "transparent" }}
aria-hidden aria-hidden
/> />
@ -358,7 +358,7 @@ function AgentSelect({
placeholder="Agent id" placeholder="Agent id"
value={value} value={value}
onChange={(e) => onChange(e.target.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={(e) =>
onChange({ template_name: e.target.value, language }) onChange({ template_name: e.target.value, language })
} }
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Language"> <FieldBlock label="Language">
@ -414,7 +414,7 @@ function SendTemplateFields({
onChange={(e) => onChange={(e) =>
onChange({ template_name: templateName, language: e.target.value }) onChange({ template_name: templateName, language: e.target.value })
} }
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
</> </>
@ -548,15 +548,15 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
} }
return ( 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 {/* 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 switch moves to the right of the save button, so the name input
gets maximum width. */} 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 <button
type="button" type="button"
onClick={() => router.push("/automations")} 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" aria-label="Back to automations"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
@ -565,9 +565,9 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
value={state.name} value={state.name}
onChange={(e) => patchTop("name", e.target.value)} onChange={(e) => patchTop("name", e.target.value)}
placeholder="Untitled automation" 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> <span className="hidden sm:inline">Active</span>
<Switch <Switch
checked={state.is_active} checked={state.is_active}
@ -587,7 +587,7 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
{/* Canvas */} {/* Canvas */}
<div className="relative flex-1 overflow-y-auto"> <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"> <div className="relative mx-auto flex max-w-2xl flex-col items-center gap-0 px-4 py-10">
<ResourcesProvider> <ResourcesProvider>
<TriggerCard <TriggerCard
@ -633,7 +633,7 @@ function TriggerCard({
// Card width: full on mobile, fixed 320px on sm+. The canvas wrapper // Card width: full on mobile, fixed 320px on sm+. The canvas wrapper
// (max-w-2xl + px-4) keeps this tidy on tablet/desktop. // (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="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 <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
@ -644,24 +644,24 @@ function TriggerCard({
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[11px] uppercase tracking-wide text-blue-300">Trigger</div> <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} {TRIGGER_OPTIONS.find((o) => o.value === type)?.label ?? type}
</div> </div>
</div> </div>
<ChevronDown <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> </button>
{open && ( {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> <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 Trigger type
</label> </label>
<select <select
value={type} value={type}
onChange={(e) => onTypeChange(e.target.value as AutomationTriggerType)} 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) => ( {TRIGGER_OPTIONS.map((o) => (
<option key={o.value} value={o.value}> <option key={o.value} value={o.value}>
@ -669,7 +669,7 @@ function TriggerCard({
</option> </option>
))} ))}
</select> </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} {TRIGGER_OPTIONS.find((o) => o.value === type)?.hint}
</p> </p>
</div> </div>
@ -681,7 +681,7 @@ function TriggerCard({
)} )}
{type === "tag_added" && ( {type === "tag_added" && (
<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">
Tag Tag
</label> </label>
<TagSelect <TagSelect
@ -697,7 +697,7 @@ function TriggerCard({
onChange={(e) => onChange={(e) =>
onConfigChange({ ...config, schedule: e.target.value }) onConfigChange({ ...config, schedule: e.target.value })
} }
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
)} )}
</div> </div>
@ -723,6 +723,18 @@ function KeywordMatchConfig({
// when the trigger type changes, so the seed stays in sync. // when the trigger type changes, so the seed stays in sync.
const [draft, setDraft] = useState(keywords.join(", ")) 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() { function commit() {
const parsed = draft const parsed = draft
.split(",") .split(",")
@ -735,7 +747,7 @@ function KeywordMatchConfig({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<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">
Keywords (comma-separated) Keywords (comma-separated)
</label> </label>
<Input <Input
@ -749,17 +761,17 @@ function KeywordMatchConfig({
} }
}} }}
placeholder="e.g. pricing, demo request, talk to sales" placeholder="e.g. pricing, demo request, talk to sales"
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</div> </div>
<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 Match type
</label> </label>
<select <select
value={config?.match_type ?? "contains"} value={config?.match_type ?? "contains"}
onChange={(e) => onChange({ ...config, match_type: e.target.value as "exact" | "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="contains">Contains</option>
<option value="exact">Exact</option> <option value="exact">Exact</option>
@ -858,7 +870,7 @@ function StepRenderer({
<div className={cn("z-10 flex flex-col", width)}> <div className={cn("z-10 flex flex-col", width)}>
<div <div
className={cn( 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, meta.border,
)} )}
> >
@ -867,28 +879,28 @@ function StepRenderer({
onClick={() => props.setExpandedId(expanded ? null : step.cid)} onClick={() => props.setExpandedId(expanded ? null : step.cid)}
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 text-left"
> >
<GripVertical className="h-4 w-4 flex-shrink-0 text-slate-600" aria-hidden /> <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-slate-800 text-slate-300"> <div className="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</div> </div>
<div className="min-w-0 flex-1"> <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"} {isCondition ? "Condition" : step.step_type === "wait" ? "Wait" : "Action"}
</div> </div>
<div className="truncate text-sm font-medium text-white">{meta.label}</div> <div className="truncate text-sm font-medium text-foreground">{meta.label}</div>
<div className="truncate text-[11px] text-slate-500">{previewFor(step)}</div> <div className="truncate text-[11px] text-muted-foreground">{previewFor(step)}</div>
</div> </div>
<ChevronDown <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> </button>
{expanded && ( {expanded && (
<div className="border-t border-slate-800 px-4 py-3"> <div className="border-t border-border px-4 py-3">
<StepEditor <StepEditor
step={step} step={step}
onChange={(next) => props.updateStep(path, () => next)} 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"> <div className="flex gap-1">
<Button <Button
variant="ghost" variant="ghost"
@ -927,9 +939,14 @@ function StepRenderer({
)} )}
</div> </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 <AddButton
onPick={(t) => props.addStepAt(parentScope, index + 1, t)} onPick={(t) => props.addStepAt(parentScope, index + 1, t)}
/> />
)}
</> </>
) )
} }
@ -990,17 +1007,17 @@ function BranchColumn({
function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) { function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {
return ( return (
<div className="relative flex flex-col items-center"> <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> <DropdownMenu>
<DropdownMenuTrigger <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" aria-label="Add step"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" 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) => { {ADDABLE_STEPS.map((t) => {
const Icon = STEP_META[t].icon const Icon = STEP_META[t].icon
@ -1013,7 +1030,7 @@ function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {
})} })}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<div className="h-4 w-[2px] bg-slate-700" aria-hidden /> <div className="h-4 w-[2px] bg-border" aria-hidden />
</div> </div>
) )
} }
@ -1041,7 +1058,7 @@ function StepEditor({
value={(cfg.text as string) ?? ""} value={(cfg.text as string) ?? ""}
onChange={(e) => set({ text: e.target.value })} onChange={(e) => set({ text: e.target.value })}
placeholder="Hi! Thanks for reaching out…" placeholder="Hi! Thanks for reaching out…"
className="min-h-24 bg-slate-800 text-white" className="min-h-24 bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
) )
@ -1070,7 +1087,7 @@ function StepEditor({
<select <select
value={(cfg.mode as string) ?? "round_robin"} value={(cfg.mode as string) ?? "round_robin"}
onChange={(e) => set({ mode: e.target.value })} 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="round_robin">Round-robin</option>
<option value="specific">Specific agent</option> <option value="specific">Specific agent</option>
@ -1100,7 +1117,7 @@ function StepEditor({
value={(cfg.value as string) ?? ""} value={(cfg.value as string) ?? ""}
onChange={(e) => set({ value: e.target.value })} onChange={(e) => set({ value: e.target.value })}
placeholder="Text or {{ vars.x }} / {{ message.text }}" placeholder="Text or {{ vars.x }} / {{ message.text }}"
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
</> </>
@ -1112,21 +1129,21 @@ function StepEditor({
<Input <Input
value={(cfg.pipeline_id as string) ?? ""} value={(cfg.pipeline_id as string) ?? ""}
onChange={(e) => set({ pipeline_id: e.target.value })} onChange={(e) => set({ pipeline_id: e.target.value })}
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Stage id"> <FieldBlock label="Stage id">
<Input <Input
value={(cfg.stage_id as string) ?? ""} value={(cfg.stage_id as string) ?? ""}
onChange={(e) => set({ stage_id: e.target.value })} onChange={(e) => set({ stage_id: e.target.value })}
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Title"> <FieldBlock label="Title">
<Input <Input
value={(cfg.title as string) ?? ""} value={(cfg.title as string) ?? ""}
onChange={(e) => set({ title: e.target.value })} onChange={(e) => set({ title: e.target.value })}
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Value"> <FieldBlock label="Value">
@ -1134,7 +1151,7 @@ function StepEditor({
type="number" type="number"
value={(cfg.value as number) ?? 0} value={(cfg.value as number) ?? 0}
onChange={(e) => set({ value: Number(e.target.value) })} onChange={(e) => set({ value: Number(e.target.value) })}
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
</> </>
@ -1148,14 +1165,14 @@ function StepEditor({
min={1} min={1}
value={(cfg.amount as number) ?? 1} value={(cfg.amount as number) ?? 1}
onChange={(e) => set({ amount: Math.max(1, Number(e.target.value)) })} onChange={(e) => set({ amount: Math.max(1, Number(e.target.value)) })}
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Unit"> <FieldBlock label="Unit">
<select <select
value={(cfg.unit as string) ?? "hours"} value={(cfg.unit as string) ?? "hours"}
onChange={(e) => set({ unit: e.target.value })} 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="minutes">Minutes</option>
<option value="hours">Hours</option> <option value="hours">Hours</option>
@ -1171,7 +1188,7 @@ function StepEditor({
<select <select
value={(cfg.subject as string) ?? "tag_presence"} value={(cfg.subject as string) ?? "tag_presence"}
onChange={(e) => set({ subject: e.target.value })} 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="tag_presence">Tag presence</option>
<option value="contact_field">Contact field</option> <option value="contact_field">Contact field</option>
@ -1192,7 +1209,7 @@ function StepEditor({
} }
value={(cfg.operand as string) ?? ""} value={(cfg.operand as string) ?? ""}
onChange={(e) => set({ operand: e.target.value })} onChange={(e) => set({ operand: e.target.value })}
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
{(cfg.subject === "contact_field" || cfg.subject === "message_content") && ( {(cfg.subject === "contact_field" || cfg.subject === "message_content") && (
@ -1200,7 +1217,7 @@ function StepEditor({
<Input <Input
value={(cfg.value as string) ?? ""} value={(cfg.value as string) ?? ""}
onChange={(e) => set({ value: e.target.value })} onChange={(e) => set({ value: e.target.value })}
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
)} )}
@ -1213,21 +1230,21 @@ function StepEditor({
<Input <Input
value={(cfg.url as string) ?? ""} value={(cfg.url as string) ?? ""}
onChange={(e) => set({ url: e.target.value })} onChange={(e) => set({ url: e.target.value })}
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
</FieldBlock> </FieldBlock>
<FieldBlock label="Body template (JSON)"> <FieldBlock label="Body template (JSON)">
<Textarea <Textarea
value={(cfg.body_template as string) ?? ""} value={(cfg.body_template as string) ?? ""}
onChange={(e) => set({ body_template: e.target.value })} 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> </FieldBlock>
</> </>
) )
case "close_conversation": case "close_conversation":
return ( 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. Sets the conversation status to &quot;closed&quot;. No configuration needed.
</p> </p>
) )
@ -1245,7 +1262,7 @@ function FieldBlock({
}) { }) {
return ( return (
<div className="mb-2 last:mb-0"> <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} {children}
</div> </div>
) )

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } 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'; type VariableType = 'static' | 'field' | 'custom_field';
@ -25,10 +25,29 @@ interface Step3Props {
template: MessageTemplate; template: MessageTemplate;
variables: Record<string, VariableMapping>; variables: Record<string, VariableMapping>;
onUpdate: (variables: Record<string, VariableMapping>) => void; 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; onNext: () => void;
onBack: () => 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 = [ const contactFields = [
{ value: 'name', label: 'Contact Name' }, { value: 'name', label: 'Contact Name' },
{ value: 'phone', label: 'Phone Number' }, { value: 'phone', label: 'Phone Number' },
@ -52,6 +71,8 @@ export function Step3Personalize({
template, template,
variables, variables,
onUpdate, onUpdate,
headerMediaUrl,
onHeaderMediaUrlChange,
onNext, onNext,
onBack, onBack,
}: Step3Props) { }: Step3Props) {
@ -112,6 +133,33 @@ export function Step3Personalize({
return [...new Set(matches)].sort(); return [...new Set(matches)].sort();
}, [template.body_text]); }, [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 * A placeholder is "unmapped" if the user hasn't picked either a
* static value or a field/custom-field source. Blocks Next until * static value or a field/custom-field source. Blocks Next until
@ -187,20 +235,69 @@ export function Step3Personalize({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h2 className="text-lg font-semibold text-white">Personalize Message</h2> <h2 className="text-lg font-semibold text-foreground">Personalize Message</h2>
<p className="mt-1 text-sm text-slate-400"> <p className="mt-1 text-sm text-muted-foreground">
Map template variables to contact fields, custom fields, or static Map template variables to contact fields, custom fields, or static
values. values.
</p> </p>
</div> </div>
{placeholders.length === 0 ? ( {mediaHeaderType && (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-6 text-center"> <div className="rounded-xl border border-border bg-card/50 p-4">
<p className="text-sm text-slate-400"> <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. This template has no variables to personalize.
</p> </p>
</div> </div>
) : ( ) : placeholders.length === 0 ? null : (
<div className="space-y-4"> <div className="space-y-4">
{placeholders.map((placeholder) => { {placeholders.map((placeholder) => {
const key = placeholder.replace(/^\{\{|\}\}$/g, ''); const key = placeholder.replace(/^\{\{|\}\}$/g, '');
@ -209,7 +306,7 @@ export function Step3Personalize({
return ( return (
<div <div
key={placeholder} 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"> <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"> <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 className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<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 Mapping Type
</label> </label>
<Select <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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="border-slate-700 bg-slate-800"> <SelectContent className="border-border bg-popover">
<SelectItem value="static">Static Value</SelectItem> <SelectItem value="static">Static Value</SelectItem>
<SelectItem value="field">Contact Field</SelectItem> <SelectItem value="field">Contact Field</SelectItem>
<SelectItem value="custom_field"> <SelectItem value="custom_field">
@ -245,7 +342,7 @@ export function Step3Personalize({
</div> </div>
<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'} {mapping.type === 'static' ? 'Value' : 'Field'}
</label> </label>
{mapping.type === 'static' ? ( {mapping.type === 'static' ? (
@ -255,7 +352,7 @@ export function Step3Personalize({
updateVariable(key, { value: e.target.value }) updateVariable(key, { value: e.target.value })
} }
placeholder="Enter 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' ? ( ) : mapping.type === 'field' ? (
<Select <Select
@ -264,10 +361,10 @@ export function Step3Personalize({
updateVariable(key, { value: val || '' }) 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..." /> <SelectValue placeholder="Select field..." />
</SelectTrigger> </SelectTrigger>
<SelectContent className="border-slate-700 bg-slate-800"> <SelectContent className="border-border bg-popover">
{contactFields.map((field) => ( {contactFields.map((field) => (
<SelectItem key={field.value} value={field.value}> <SelectItem key={field.value} value={field.value}>
{field.label} {field.label}
@ -282,7 +379,7 @@ export function Step3Personalize({
updateVariable(key, { value: val || '' }) 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 <SelectValue
placeholder={ placeholder={
loadingFields loadingFields
@ -293,7 +390,7 @@ export function Step3Personalize({
} }
/> />
</SelectTrigger> </SelectTrigger>
<SelectContent className="border-slate-700 bg-slate-800"> <SelectContent className="border-border bg-popover">
{customFields.map((f) => ( {customFields.map((f) => (
<SelectItem key={f.id} value={f.id}> <SelectItem key={f.id} value={f.id}>
{f.field_name} {f.field_name}
@ -312,11 +409,11 @@ export function Step3Personalize({
{/* Live Preview — rendered as a WhatsApp-style bubble so the user {/* Live Preview — rendered as a WhatsApp-style bubble so the user
sees approximately what the recipient will see. */} 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"> <div className="mb-3 flex items-center gap-2">
<Eye className="h-4 w-4 text-primary" /> <Eye className="h-4 w-4 text-primary" />
<p className="text-sm font-medium text-white">Live Preview</p> <p className="text-sm font-medium text-foreground">Live Preview</p>
<span className="text-xs text-slate-500">({previewLabel})</span> <span className="text-xs text-muted-foreground">({previewLabel})</span>
{loadingPreview && ( {loadingPreview && (
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" /> <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
)} )}
@ -340,18 +437,18 @@ export function Step3Personalize({
</div> </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 <Button
variant="outline" variant="outline"
onClick={onBack} onClick={onBack}
className="border-slate-700 text-slate-300" className="border-border text-muted-foreground"
> >
<ArrowLeft className="h-4 w-4" /> <ArrowLeft className="h-4 w-4" />
Back Back
</Button> </Button>
<Button <Button
onClick={onNext} 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" className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
> >
Next Next

View File

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

View File

@ -5,7 +5,11 @@ import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { formatCurrency } from '@/lib/currency'; import { formatCurrency } from '@/lib/currency';
import { toast } from 'sonner'; 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 { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -33,6 +37,7 @@ import {
Save, Save,
X, X,
DollarSign, DollarSign,
LayoutTemplate,
} from 'lucide-react'; } from 'lucide-react';
interface ContactDetailViewProps { interface ContactDetailViewProps {
@ -55,6 +60,12 @@ export function ContactDetailView({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [copiedPhone, setCopiedPhone] = 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 // Details tab
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [editPhone, setEditPhone] = useState(''); const [editPhone, setEditPhone] = useState('');
@ -317,6 +328,48 @@ export function ContactDetailView({
setSavingCustom(false); 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) { function getInitials(name?: string | null) {
if (!name) return '?'; if (!name) return '?';
return name return name
@ -328,10 +381,11 @@ export function ContactDetailView({
} }
return ( return (
<>
<Sheet open={open} onOpenChange={onOpenChange}> <Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent <SheetContent
side="right" 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 ? ( {loading || !contact ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
@ -340,21 +394,21 @@ export function ContactDetailView({
) : ( ) : (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */} {/* 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"> <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"> <AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
{getInitials(contact.name)} {getInitials(contact.name)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<SheetTitle className="text-white truncate"> <SheetTitle className="text-popover-foreground truncate">
{contact.name || 'Unknown'} {contact.name || 'Unknown'}
</SheetTitle> </SheetTitle>
<SheetDescription className="text-slate-400 text-xs mt-0.5"> <SheetDescription className="text-muted-foreground text-xs mt-0.5">
Contact details Contact details
</SheetDescription> </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 <button
onClick={copyPhone} onClick={copyPhone}
className="flex items-center gap-1 hover:text-primary transition-colors cursor-pointer" className="flex items-center gap-1 hover:text-primary transition-colors cursor-pointer"
@ -382,38 +436,53 @@ export function ContactDetailView({
</div> </div>
</div> </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> </SheetHeader>
{/* Tabs */} {/* Tabs */}
<Tabs defaultValue="details" className="flex-1 flex flex-col min-h-0"> <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 <TabsTrigger
value="details" 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 Details
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="tags" 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 Tags
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="notes" 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 Notes
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="custom" 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 Custom Fields
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="deals" 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 Deals
</TabsTrigger> </TabsTrigger>
@ -423,37 +492,37 @@ export function ContactDetailView({
<TabsContent value="details" className="flex-1 overflow-y-auto px-4 py-3"> <TabsContent value="details" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-3"> <div className="space-y-3">
<div className="space-y-1.5"> <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 <Input
value={editName} value={editName}
onChange={(e) => setEditName(e.target.value)} 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>
<div className="space-y-1.5"> <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> Phone <span className="text-red-400">*</span>
</Label> </Label>
<Input <Input
value={editPhone} value={editPhone}
onChange={(e) => setEditPhone(e.target.value)} 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>
<div className="space-y-1.5"> <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 <Input
value={editEmail} value={editEmail}
onChange={(e) => setEditEmail(e.target.value)} 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>
<div className="space-y-1.5"> <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 <Input
value={editCompany} value={editCompany}
onChange={(e) => setEditCompany(e.target.value)} 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> </div>
<Button <Button
@ -475,11 +544,11 @@ export function ContactDetailView({
{/* Tags Tab */} {/* Tags Tab */}
<TabsContent value="tags" className="flex-1 overflow-y-auto px-4 py-3"> <TabsContent value="tags" className="flex-1 overflow-y-auto px-4 py-3">
<div className="space-y-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. Click a tag to add or remove it from this contact.
</p> </p>
{allTags.length === 0 ? ( {allTags.length === 0 ? (
<p className="text-sm text-slate-500"> <p className="text-sm text-muted-foreground">
No tags available. Create tags in Settings. No tags available. Create tags in Settings.
</p> </p>
) : ( ) : (
@ -493,7 +562,7 @@ export function ContactDetailView({
disabled={savingTags} disabled={savingTags}
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium transition-all cursor-pointer ${ className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium transition-all cursor-pointer ${
selected 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' : 'opacity-50 hover:opacity-80'
}`} }`}
style={{ style={{
@ -518,7 +587,7 @@ export function ContactDetailView({
value={newNote} value={newNote}
onChange={(e) => setNewNote(e.target.value)} onChange={(e) => setNewNote(e.target.value)}
placeholder="Write a note..." 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 <Button
onClick={addNote} onClick={addNote}
@ -538,30 +607,30 @@ export function ContactDetailView({
<div className="flex-1 overflow-y-auto space-y-2"> <div className="flex-1 overflow-y-auto space-y-2">
{loadingNotes ? ( {loadingNotes ? (
<div className="flex items-center justify-center py-8"> <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> </div>
) : notes.length === 0 ? ( ) : 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. No notes yet.
</p> </p>
) : ( ) : (
notes.map((note) => ( notes.map((note) => (
<div <div
key={note.id} 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"> <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} {note.note_text}
</p> </p>
<button <button
onClick={() => deleteNote(note.id)} 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" /> <Trash2 className="size-3.5" />
</button> </button>
</div> </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', { {new Date(note.created_at).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
@ -580,17 +649,17 @@ export function ContactDetailView({
<TabsContent value="custom" className="flex-1 overflow-y-auto px-4 py-3"> <TabsContent value="custom" className="flex-1 overflow-y-auto px-4 py-3">
{loadingCustom ? ( {loadingCustom ? (
<div className="flex items-center justify-center py-8"> <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> </div>
) : customFields.length === 0 ? ( ) : 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. No custom fields defined. Create them in Settings.
</p> </p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{customFields.map((field) => ( {customFields.map((field) => (
<div key={field.id} className="space-y-1.5"> <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} {field.field_name}
</Label> </Label>
<Input <Input
@ -602,7 +671,7 @@ export function ContactDetailView({
})) }))
} }
placeholder={`Enter ${field.field_name}...`} 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> </div>
))} ))}
@ -630,16 +699,16 @@ export function ContactDetailView({
<Loader2 className="size-5 animate-spin text-primary" /> <Loader2 className="size-5 animate-spin text-primary" />
</div> </div>
) : deals.length === 0 ? ( ) : 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"> <div className="space-y-2">
{deals.map((deal) => ( {deals.map((deal) => (
<div <div
key={deal.id} 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"> <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} {deal.title}
</p> </p>
{deal.stage && ( {deal.stage && (
@ -654,7 +723,7 @@ export function ContactDetailView({
</span> </span>
)} )}
</div> </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"> <span className="flex items-center gap-1">
<DollarSign className="size-3" /> <DollarSign className="size-3" />
{formatCurrency( {formatCurrency(
@ -684,5 +753,11 @@ export function ContactDetailView({
)} )}
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<TemplatePicker
open={templatePickerOpen}
onOpenChange={setTemplatePickerOpen}
onSelect={handleSendTemplate}
/>
</>
); );
} }

View File

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

View File

@ -33,10 +33,10 @@ export function CustomFieldsManager({
}: CustomFieldsManagerProps) { }: CustomFieldsManagerProps) {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle className="text-white">Custom fields</DialogTitle> <DialogTitle className="text-popover-foreground">Custom fields</DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-muted-foreground">
Define extra contact fields (e.g. ZIP code, lead source). They Define extra contact fields (e.g. ZIP code, lead source). They
appear on every contact and in the “Update Contact Field” automation appear on every contact and in the “Update Contact Field” automation
action. action.
@ -185,7 +185,7 @@ export function CustomFieldsPanel() {
} }
}} }}
placeholder="New field name…" placeholder="New field name…"
className="bg-slate-800 text-white" className="bg-muted text-foreground"
/> />
<Button <Button
onClick={handleCreate} onClick={handleCreate}
@ -202,18 +202,18 @@ export function CustomFieldsPanel() {
</div> </div>
{/* List */} {/* 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 ? ( {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" /> <Loader2 className="size-4 animate-spin" />
Loading… Loading…
</div> </div>
) : fields.length === 0 ? ( ) : 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. No custom fields yet.
</p> </p>
) : ( ) : (
<ul className="divide-y divide-slate-800"> <ul className="divide-y divide-border">
{fields.map((field) => ( {fields.map((field) => (
<FieldRow <FieldRow
key={field.id} key={field.id}
@ -265,7 +265,7 @@ function FieldRow({
if (e.key === 'Enter') e.currentTarget.blur(); if (e.key === 'Enter') e.currentTarget.blur();
}} }}
aria-label={`Rename ${field.field_name}`} 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 <Button
variant="ghost" variant="ghost"
@ -273,7 +273,7 @@ function FieldRow({
disabled={busy} disabled={busy}
onClick={() => onDelete(field)} onClick={() => onDelete(field)}
title="Delete 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 ? ( {busy ? (
<Loader2 className="size-4 animate-spin" /> <Loader2 className="size-4 animate-spin" />

View File

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useMemo, useRef, useState } from 'react';
import { createClient } from '@/lib/supabase/client'; import { createClient } from '@/lib/supabase/client';
import { useAuth } from '@/hooks/use-auth'; import { useAuth } from '@/hooks/use-auth';
import { import {
@ -8,6 +8,16 @@ import {
isUniqueViolation, isUniqueViolation,
normalizeKey, normalizeKey,
} from '@/lib/contacts/dedupe'; } 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 { toast } from 'sonner';
import { import {
Dialog, Dialog,
@ -18,7 +28,89 @@ import {
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; 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 { interface ImportModalProps {
open: boolean; open: boolean;
@ -26,87 +118,43 @@ interface ImportModalProps {
onImported: () => void; onImported: () => void;
} }
interface ParsedRow { export function ImportModal({
phone: string; open,
name?: string; onOpenChange,
email?: string; onImported,
company?: string; }: ImportModalProps) {
}
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) {
const supabase = createClient(); const supabase = createClient();
const { accountId } = useAuth(); const { accountId, canEditSettings } = useAuth();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [file, setFile] = useState<File | null>(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 [importing, setImporting] = useState(false);
const [result, setResult] = useState<{ const [result, setResult] = useState<{
imported: number; imported: number;
skipped: number; skipped: number;
failed: number; failed: number;
tagsAssigned: number;
} | null>(null); } | null>(null);
function reset() { function reset() {
setFile(null); setFile(null);
setParsedRows([]); setParsedRows([]);
setHasTagsColumn(false);
setHasCompanyColumn(false);
setTagColorByKey(new Map());
setResult(null); setResult(null);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
} }
function handleOpenChange(open: boolean) { function handleOpenChange(next: boolean) {
if (!open) reset(); if (!next) reset();
onOpenChange(open); onOpenChange(next);
} }
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) { async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
@ -117,15 +165,42 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
setResult(null); setResult(null);
const text = await selected.text(); const text = await selected.text();
const rows = parseCSV(text); const {
rows,
hasTagsColumn: csvHasTags,
hasCompanyColumn: csvHasCompany,
} = parseContactCsv(text);
if (rows.length === 0) { 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([]); setParsedRows([]);
setHasTagsColumn(false);
setHasCompanyColumn(false);
setTagColorByKey(new Map());
return; return;
} }
setParsedRows(rows); 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() { async function handleImport() {
@ -138,7 +213,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} = await supabase.auth.getSession(); } = await supabase.auth.getSession();
const user = session?.user; const user = session?.user;
if (!user) throw new Error('Not authenticated'); 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 imported = 0;
let skipped = 0; let skipped = 0;
@ -156,8 +232,10 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
.eq('account_id', accountId); .eq('account_id', accountId);
const existing = new Set( const existing = new Set(
(existingRows ?? []) (existingRows ?? [])
.map((r) => (r as { phone_normalized: string | null }).phone_normalized) .map(
.filter((p): p is string => !!p), (r) => (r as { phone_normalized: string | null }).phone_normalized
)
.filter((p): p is string => !!p)
); );
const toInsert = unique.filter((row) => { const toInsert = unique.filter((row) => {
@ -168,10 +246,27 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
return true; 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 // unique index is the backstop: a 23505 (race, or a format
// that normalizes equal) counts as skipped, not failed. // that normalizes equal) counts as skipped, not failed.
const chunkSize = 50; const chunkSize = 50;
for (let i = 0; i < toInsert.length; i += chunkSize) { for (let i = 0; i < toInsert.length; i += chunkSize) {
const chunk = toInsert.slice(i, i + chunkSize); const chunk = toInsert.slice(i, i + chunkSize);
const rows = chunk.map((row) => ({ const rows = chunk.map((row) => ({
@ -191,10 +286,23 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
if (error) { if (error) {
// Retry individually so one bad/duplicate row doesn't sink // Retry individually so one bad/duplicate row doesn't sink
// the whole chunk. // the whole chunk.
for (const row of rows) { for (let j = 0; j < rows.length; j++) {
const { error: singleErr } = await supabase.from('contacts').insert(row); const row = rows[j];
if (!singleErr) { const source = chunk[j];
const { data: singleData, error: singleErr } = await supabase
.from('contacts')
.insert(row)
.select('id')
.single();
if (!singleErr && singleData) {
imported++; imported++;
if (source.tagNames.length > 0) {
tagAssignments.push({
contactId: singleData.id,
tagNames: source.tagNames,
});
}
} else if (isUniqueViolation(singleErr)) { } else if (isUniqueViolation(singleErr)) {
skipped++; skipped++;
} else { } else {
@ -202,20 +310,62 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
} }
} }
} else { } 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) { if (imported > 0) {
toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`); toast.success(
`${imported} contact${imported !== 1 ? 's' : ''} imported`
);
onImported(); 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) { if (skipped > 0) {
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`); toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`);
} }
if (failed > 0) { 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) { } catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Import failed'; 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 ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg"> <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">
<DialogHeader> <div className="shrink-0 space-y-4 border-b border-border/80 px-6 pt-6 pb-5">
<DialogTitle className="text-white">Import Contacts</DialogTitle> <DialogHeader className="gap-1.5">
<DialogDescription className="text-slate-400"> <DialogTitle className="text-lg text-popover-foreground">
Upload a CSV file with a &quot;phone&quot; column (required). Optional columns: Import Contacts
name, email, company. </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> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4">
{/* Upload area */}
<div <div
role="button"
tabIndex={0}
onClick={() => fileInputRef.current?.click()} 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 ? ( {file ? (
<> <>
<FileText className="size-8 text-primary" /> <div className="bg-primary/15 ring-primary/25 flex size-10 items-center justify-center rounded-lg ring-1">
<p className="text-sm text-slate-300">{file.name}</p> <FileText className="text-primary size-5" />
<p className="text-xs text-slate-500"> </div>
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} detected <p
className="max-w-full truncate px-2 text-sm font-medium text-popover-foreground"
title={file.name}
>
{truncateFilename(file.name)}
</p> </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" /> <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">
<p className="text-sm text-slate-400"> <Upload className="size-5 text-muted-foreground group-hover:text-foreground" />
Click to upload CSV file </div>
<p className="text-sm text-muted-foreground">
Click to choose a CSV file
</p> </p>
<p className="text-xs text-slate-500"> <p className="text-[11px] text-muted-foreground">
CSV with &quot;phone&quot; column required .csv up to your browser limit
</p> </p>
</> </>
)} )}
@ -272,63 +482,136 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
onChange={handleFileChange} onChange={handleFileChange}
className="hidden" className="hidden"
/> />
</div>
{/* Preview table */} <div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
{preview.length > 0 && !result && ( {preview.length > 0 && !result && (
<div className="space-y-2"> <div className="space-y-3">
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider"> <div className="flex flex-wrap items-center justify-between gap-2">
Preview (first {preview.length} rows) <p className="text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
Preview · first {preview.length}
</p> </p>
<div className="rounded-lg border border-slate-700 overflow-hidden"> <div className="flex flex-wrap items-center gap-1.5">
<table className="w-full text-xs"> {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> <thead>
<tr className="bg-slate-800"> <tr className="border-b border-border bg-background/60">
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Phone</th> <th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Name</th> Phone
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Email</th> </th>
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Company</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> </tr>
</thead> </thead>
<tbody> <tbody className="divide-y divide-border/70">
{preview.map((row, i) => ( {preview.map((row, i) => (
<tr key={i} className="border-t border-slate-700/50"> <tr
<td className="px-3 py-1.5 text-slate-300">{row.phone}</td> key={i}
<td className="px-3 py-1.5 text-slate-300">{row.name || '-'}</td> className="bg-popover/40 transition-colors hover:bg-muted/30"
<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> <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> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
{parsedRows.length > 5 && ( </div>
<p className="text-xs text-slate-500">
...and {parsedRows.length - 5} more rows {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> </p>
)} )}
</div> </div>
)} )}
{/* Results */}
{result && ( {result && (
<div className="rounded-lg border border-slate-700 p-4 space-y-2"> <div className="rounded-xl border border-border bg-background/50 p-4">
<p className="text-sm font-medium text-white">Import Complete</p> <p className="text-sm font-medium text-popover-foreground">Import complete</p>
<div className="flex flex-wrap items-center gap-4"> <div className="mt-3 flex flex-wrap gap-3">
{result.imported > 0 && ( {result.imported > 0 && (
<div className="flex items-center gap-1.5 text-primary text-sm"> <div className="text-primary flex items-center gap-1.5 text-sm">
<CheckCircle className="size-4" /> <CheckCircle className="size-4 shrink-0" />
{result.imported} imported {result.imported} imported
</div> </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 && ( {result.skipped > 0 && (
<div className="flex items-center gap-1.5 text-amber-400 text-sm"> <div className="flex items-center gap-1.5 text-sm text-amber-400">
<AlertTriangle className="size-4" /> <AlertTriangle className="size-4 shrink-0" />
{result.skipped} duplicate{result.skipped !== 1 ? 's' : ''} skipped {result.skipped} skipped
</div> </div>
)} )}
{result.failed > 0 && ( {result.failed > 0 && (
<div className="flex items-center gap-1.5 text-red-400 text-sm"> <div className="flex items-center gap-1.5 text-sm text-red-400">
<XCircle className="size-4" /> <XCircle className="size-4 shrink-0" />
{result.failed} failed {result.failed} failed
</div> </div>
)} )}
@ -337,12 +620,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
)} )}
</div> </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 <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={() => handleOpenChange(false)} 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'} {result ? 'Close' : 'Cancel'}
</Button> </Button>
@ -354,7 +637,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
> >
{importing && <Loader2 className="size-4 animate-spin" />} {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> </Button>
)} )}
</DialogFooter> </DialogFooter>

View File

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

View File

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

View File

@ -23,18 +23,18 @@ interface MetricCardProps {
export function MetricCard({ title, value, icon: Icon, delta, subtitle }: MetricCardProps) { export function MetricCard({ title, value, icon: Icon, delta, subtitle }: MetricCardProps) {
return ( 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"> <div className="flex items-start justify-between">
<p className="text-sm font-medium text-slate-400">{title}</p> <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-slate-800 text-slate-500"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
</div> </div>
</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} {value}
</p> </p>
{delta ? <DeltaRow sign={delta.sign} label={delta.label} /> : subtitle ? ( {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} ) : null}
</div> </div>
) )
@ -46,7 +46,7 @@ function DeltaRow({ sign, label }: { sign: number; label: string }) {
? 'text-primary' ? 'text-primary'
: sign < 0 : sign < 0
? 'text-red-400' ? 'text-red-400'
: 'text-slate-500' : 'text-muted-foreground'
const Arrow = sign > 0 ? ArrowUp : sign < 0 ? ArrowDown : Minus const Arrow = sign > 0 ? ArrowUp : sign < 0 ? ArrowDown : Minus
return ( return (
<div className={cn('mt-2 flex items-center gap-1 text-sm', tone)}> <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) { export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
return ( return (
<section className="flex h-full flex-col rounded-xl border border-slate-800 bg-slate-900"> <section className="flex h-full flex-col rounded-xl border border-border bg-card">
<header className="border-b border-slate-800 px-5 py-4"> <header className="border-b border-border px-5 py-4">
<h2 className="text-sm font-semibold text-white">Pipeline Value</h2> <h2 className="text-sm font-semibold text-foreground">Pipeline Value</h2>
<p className="mt-0.5 text-xs text-slate-500"> <p className="mt-0.5 text-xs text-muted-foreground">
Open deals by stage Open deals by stage
</p> </p>
</header> </header>
@ -43,11 +43,11 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
style={{ background: s.color }} style={{ background: s.color }}
aria-hidden aria-hidden
/> />
<span className="flex-1 truncate text-slate-300">{s.name}</span> <span className="flex-1 truncate text-muted-foreground">{s.name}</span>
<span className="text-slate-500 tabular-nums"> <span className="text-muted-foreground tabular-nums">
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'} {s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
</span> </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)} {formatCurrencyShort(s.totalValue, currency)}
</span> </span>
</li> </li>
@ -98,7 +98,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
<div className="flex items-center justify-center"> <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"> <svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage">
{/* background ring */} {/* 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) => ( {segments.map((seg) => (
<path <path
key={seg.id} key={seg.id}
@ -114,7 +114,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
x={cx} x={cx}
y={cy - 6} y={cy - 6}
textAnchor="middle" textAnchor="middle"
className="fill-slate-500 text-[11px]" className="fill-muted-foreground text-[11px]"
> >
Total Total
</text> </text>
@ -122,7 +122,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
x={cx} x={cx}
y={cy + 14} y={cy + 14}
textAnchor="middle" textAnchor="middle"
className="fill-white text-[18px] font-semibold tabular-nums" className="fill-foreground text-[18px] font-semibold tabular-nums"
> >
{formatCurrencyShort(data.totalValue, currency)} {formatCurrencyShort(data.totalValue, currency)}
</text> </text>

View File

@ -31,12 +31,12 @@ export function QuickActions() {
<Link <Link
key={a.href} key={a.href}
href={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" /> <Icon className="h-4 w-4" />
</div> </div>
<span className="text-sm font-medium text-white">{a.label}</span> <span className="text-sm font-medium text-foreground">{a.label}</span>
</Link> </Link>
) )
})} })}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +1,19 @@
"use client"; "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, * Lays the editor out as one app-like column that fills the dashboard
* and conditionally mounts `<FlowCanvas>` or `<FlowBuilder>`. Why a * content area (toolbar → mode row → stage → validation bar), matching
* separate component: * 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). * - The page itself stays trivially small (loading + error + this).
* - Either view can stay unaware of the other — they share data * - Either view can stay unaware of the other — they share data
* (`{flow, nodes}`) and nothing else. * (`{flow, nodes}`) and nothing else.
@ -17,13 +25,14 @@
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LayoutGrid, ListTree } from "lucide-react"; import { GitFork, List } from "lucide-react";
import { FlowBuilder } from "./flow-builder"; import { FlowBuilder } from "./flow-builder";
import { FlowCanvas } from "./flow-canvas"; import { FlowCanvas } from "./flow-canvas";
import { FlowEditorProvider } from "./flow-editor-state"; import { FlowEditorProvider } from "./flow-editor-state";
import { EditorHeader } from "./header"; import { EditorHeader } from "./header";
import { ValidationPanel } from "./validation-panel"; import { ValidationPanel } from "./validation-panel";
import { NODE_META, nodeColors, type NodeType } from "./shared";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types"; import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
@ -39,6 +48,11 @@ type View = "canvas" | "list";
const STORAGE_KEY = "wacrm.flowEditor.view"; 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 { interface Props {
initialFlow: FlowRow; initialFlow: FlowRow;
initialNodes: FlowNodeRow[]; initialNodes: FlowNodeRow[];
@ -78,37 +92,63 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
return ( return (
<FlowEditorProvider initialFlow={initialFlow} initialNodes={initialNodes}> <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 /> <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 && ( {!isMobile && (
<div className="flex items-center justify-end"> <div className="flex items-center gap-4 px-6 py-3.5">
<div <div
role="group" role="group"
aria-label="Editor view" 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"} active={effectiveView === "canvas"}
onClick={() => choose("canvas")} onClick={() => choose("canvas")}
icon={<LayoutGrid className="h-3 w-3" />} icon={<GitFork className="h-3.5 w-3.5" />}
label="Canvas" label="Canvas"
/> />
<ToggleButton <SegButton
active={effectiveView === "list"} active={effectiveView === "list"}
onClick={() => choose("list")} onClick={() => choose("list")}
icon={<ListTree className="h-3 w-3" />} icon={<List className="h-3.5 w-3.5" />}
label="List" label="List"
/> />
</div> </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> </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 {/* ---- validation / activate-readiness bar ---- */}
when this lived inside FlowBuilder — the activate-readiness <div className="px-6 pb-5 pt-3">
status follows the user as they scroll, in either view. */}
<div className="sticky bottom-4 z-10 shadow-xl shadow-slate-950/60">
<ValidationPanel /> <ValidationPanel />
</div> </div>
</div> </div>
@ -138,7 +178,7 @@ function useMatchMedia(query: string): boolean {
return matches; return matches;
} }
function ToggleButton({ function SegButton({
active, active,
onClick, onClick,
icon, icon,
@ -155,10 +195,10 @@ function ToggleButton({
onClick={onClick} onClick={onClick}
aria-pressed={active} aria-pressed={active}
className={cn( 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 active
? "bg-slate-700 text-slate-100" ? "bg-card text-foreground shadow-sm"
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200", : "text-muted-foreground hover:text-foreground",
)} )}
> >
{icon} {icon}

View File

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

View File

@ -45,7 +45,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils"; 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 { slugify, type BuilderNode } from "../shared";
import { NextNodeRow, NodeKeySelect, TextRow } from "./fields"; import { NextNodeRow, NodeKeySelect, TextRow } from "./fields";
@ -135,7 +135,7 @@ export function NodeConfigForm({
rows={2} rows={2}
/> />
<div> <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) Variable key (stored in flow_runs.vars; alphanumeric + underscore)
</label> </label>
<Input <Input
@ -146,11 +146,11 @@ export function NodeConfigForm({
}) })
} }
placeholder="e.g. name, email, company" 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{" "} Interpolate in downstream prompts and handoff notes with{" "}
<code className="rounded bg-slate-800 px-1"> <code className="rounded bg-muted px-1">
{"{{vars."} {"{{vars."}
{(cfg as { var_key?: string }).var_key || "name"} {(cfg as { var_key?: string }).var_key || "name"}
{"}}"} {"}}"}
@ -200,7 +200,7 @@ export function NodeConfigForm({
case "end": case "end":
return ( 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 Terminal node. When the runner reaches this node the run is marked
complete. No config needed. complete. No config needed.
</p> </p>
@ -269,7 +269,7 @@ function SendButtonsForm({
/> />
<div> <div>
<div className="mb-2 flex items-center justify-between"> <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 Buttons (1–3) — each one routes to a different next node
</label> </label>
</div> </div>
@ -278,7 +278,7 @@ function SendButtonsForm({
<div <div
key={i} key={i}
className={cn( 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 showAdvanced
? "md:grid-cols-[1fr_2fr_2fr_auto]" ? "md:grid-cols-[1fr_2fr_2fr_auto]"
: "md:grid-cols-[2fr_2fr_auto]", : "md:grid-cols-[2fr_2fr_auto]",
@ -293,14 +293,14 @@ function SendButtonsForm({
}) })
} }
placeholder="reply_id" placeholder="reply_id"
className="bg-slate-800 font-mono text-xs" className="bg-muted font-mono text-xs"
/> />
)} )}
<Input <Input
value={b.title} value={b.title}
onChange={(e) => updateButton(i, { title: e.target.value })} onChange={(e) => updateButton(i, { title: e.target.value })}
placeholder="Visible title (≤20 chars)" placeholder="Visible title (≤20 chars)"
className="bg-slate-800" className="bg-muted"
maxLength={20} maxLength={20}
/> />
<NodeKeySelect <NodeKeySelect
@ -465,13 +465,13 @@ function SendListForm({
</div> </div>
<div className="mt-2"> <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) Rows (1–10 total across all sections)
</label> </label>
{sections.map((section, sIdx) => ( {sections.map((section, sIdx) => (
<div <div
key={sIdx} 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"> <div className="mb-2 flex items-center gap-2">
<Input <Input
@ -480,7 +480,7 @@ function SendListForm({
updateSection(sIdx, { title: e.target.value }) updateSection(sIdx, { title: e.target.value })
} }
placeholder={`Section ${sIdx + 1} title (optional)`} placeholder={`Section ${sIdx + 1} title (optional)`}
className="bg-slate-800 text-xs" className="bg-muted text-xs"
/> />
{sections.length > 1 && ( {sections.length > 1 && (
<Button <Button
@ -516,7 +516,7 @@ function SendListForm({
}) })
} }
placeholder="reply_id" placeholder="reply_id"
className="bg-slate-800 font-mono text-xs" className="bg-muted font-mono text-xs"
/> />
)} )}
<Input <Input
@ -525,7 +525,7 @@ function SendListForm({
updateRow(sIdx, rIdx, { title: e.target.value }) updateRow(sIdx, rIdx, { title: e.target.value })
} }
placeholder="Row title (≤24)" placeholder="Row title (≤24)"
className="bg-slate-800" className="bg-muted"
maxLength={24} maxLength={24}
/> />
<NodeKeySelect <NodeKeySelect
@ -614,14 +614,14 @@ function ConditionForm({
<> <>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3"> <div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div> <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 <Select
value={subject} value={subject}
onValueChange={(v) => onValueChange={(v) =>
onUpdateConfig({ subject: v as ConditionCfg["subject"] }) onUpdateConfig({ subject: v as ConditionCfg["subject"] })
} }
> >
<SelectTrigger className="bg-slate-800"> <SelectTrigger className="bg-muted">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -632,7 +632,7 @@ function ConditionForm({
</Select> </Select>
</div> </div>
<div className="md:col-span-2"> <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" {subject === "var"
? "var name" ? "var name"
: subject === "tag" : subject === "tag"
@ -644,7 +644,7 @@ function ConditionForm({
value={cfg.subject_key ?? ""} value={cfg.subject_key ?? ""}
onValueChange={(v) => onUpdateConfig({ subject_key: v })} onValueChange={(v) => onUpdateConfig({ subject_key: v })}
> >
<SelectTrigger className="bg-slate-800"> <SelectTrigger className="bg-muted">
<SelectValue placeholder="Pick a tag…" /> <SelectValue placeholder="Pick a tag…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -660,7 +660,7 @@ function ConditionForm({
value={cfg.subject_key ?? ""} value={cfg.subject_key ?? ""}
onValueChange={(v) => onUpdateConfig({ subject_key: v })} onValueChange={(v) => onUpdateConfig({ subject_key: v })}
> >
<SelectTrigger className="bg-slate-800"> <SelectTrigger className="bg-muted">
<SelectValue placeholder="Pick a field…" /> <SelectValue placeholder="Pick a field…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -677,7 +677,7 @@ function ConditionForm({
onUpdateConfig({ subject_key: e.target.value }) onUpdateConfig({ subject_key: e.target.value })
} }
placeholder={subject === "var" ? "e.g. email" : "tag UUID"} placeholder={subject === "var" ? "e.g. email" : "tag UUID"}
className="bg-slate-800 font-mono text-xs" className="bg-muted font-mono text-xs"
/> />
)} )}
</div> </div>
@ -690,14 +690,14 @@ function ConditionForm({
)} )}
> >
<div> <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 <Select
value={operator} value={operator}
onValueChange={(v) => onValueChange={(v) =>
onUpdateConfig({ operator: v as ConditionCfg["operator"] }) onUpdateConfig({ operator: v as ConditionCfg["operator"] })
} }
> >
<SelectTrigger className="bg-slate-800"> <SelectTrigger className="bg-muted">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -710,11 +710,11 @@ function ConditionForm({
</div> </div>
{showValue && ( {showValue && (
<div> <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 <Input
value={cfg.value ?? ""} value={cfg.value ?? ""}
onChange={(e) => onUpdateConfig({ value: e.target.value })} onChange={(e) => onUpdateConfig({ value: e.target.value })}
className="bg-slate-800" className="bg-muted"
/> />
</div> </div>
)} )}
@ -767,14 +767,14 @@ function SetTagForm({
<> <>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2"> <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<div> <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 <Select
value={cfg.mode ?? "add"} value={cfg.mode ?? "add"}
onValueChange={(v) => onValueChange={(v) =>
onUpdateConfig({ mode: v as SetTagCfg["mode"] }) onUpdateConfig({ mode: v as SetTagCfg["mode"] })
} }
> >
<SelectTrigger className="bg-slate-800"> <SelectTrigger className="bg-muted">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -784,13 +784,13 @@ function SetTagForm({
</Select> </Select>
</div> </div>
<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 ? ( {tags.length > 0 ? (
<Select <Select
value={cfg.tag_id ?? ""} value={cfg.tag_id ?? ""}
onValueChange={(v) => onUpdateConfig({ tag_id: v })} onValueChange={(v) => onUpdateConfig({ tag_id: v })}
> >
<SelectTrigger className="bg-slate-800"> <SelectTrigger className="bg-muted">
<SelectValue placeholder="Pick a tag…" /> <SelectValue placeholder="Pick a tag…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -806,7 +806,7 @@ function SetTagForm({
value={cfg.tag_id ?? ""} value={cfg.tag_id ?? ""}
onChange={(e) => onUpdateConfig({ tag_id: e.target.value })} onChange={(e) => onUpdateConfig({ tag_id: e.target.value })}
placeholder="Tag UUID" placeholder="Tag UUID"
className="bg-slate-800 font-mono text-xs" className="bg-muted font-mono text-xs"
/> />
)} )}
</div> </div>
@ -872,8 +872,6 @@ const MEDIA_ACCEPT: Record<NonNullable<SendMediaCfg["media_type"]>, string> = {
}; };
const FLOW_MEDIA_BUCKET = "flow-media"; 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({ function SendMediaForm({
cfg, cfg,
@ -897,7 +895,7 @@ function SendMediaForm({
const handleFile = useCallback( const handleFile = useCallback(
async (file: File) => { async (file: File) => {
if (file.size > FLOW_MEDIA_MAX_BYTES) { if (file.size > MEDIA_MAX_BYTES) {
toast.error( toast.error(
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`, `File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`,
); );
@ -905,49 +903,9 @@ function SendMediaForm({
} }
setUploading(true); setUploading(true);
try { try {
const supabase = createClient(); // Account-scoped upload (path `account-<id>/...`) — see
const { // uploadAccountMedia + migration 020's flow-media RLS policy.
data: { user }, const { publicUrl } = await uploadAccountMedia(FLOW_MEDIA_BUCKET, file);
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);
// Patch all fields in one call so the form doesn't re-render // Patch all fields in one call so the form doesn't re-render
// with a half-uploaded state. // with a half-uploaded state.
onUpdateConfig({ onUpdateConfig({
@ -972,7 +930,7 @@ function SendMediaForm({
return ( return (
<> <>
<div> <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 <Select
value={mediaType} value={mediaType}
onValueChange={(v) => { onValueChange={(v) => {
@ -986,7 +944,7 @@ function SendMediaForm({
}); });
}} }}
> >
<SelectTrigger className="bg-slate-800"> <SelectTrigger className="bg-muted">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -1000,15 +958,15 @@ function SendMediaForm({
</div> </div>
<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 ? ( {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" /> <Paperclip className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
<a <a
href={cfg.media_url} href={cfg.media_url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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} title={displayName || cfg.media_url}
> >
{displayName || cfg.media_url} {displayName || cfg.media_url}
@ -1016,7 +974,7 @@ function SendMediaForm({
<button <button
type="button" type="button"
onClick={handleClear} 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" aria-label="Remove file"
disabled={uploading} disabled={uploading}
> >
@ -1028,7 +986,7 @@ function SendMediaForm({
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={uploading} 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 ? ( {uploading ? (
<> <>
@ -1066,14 +1024,14 @@ function SendMediaForm({
{isDocument && ( {isDocument && (
<div> <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) Filename shown to the customer (documents only)
</label> </label>
<Input <Input
value={cfg.filename ?? ""} value={cfg.filename ?? ""}
onChange={(e) => onUpdateConfig({ filename: e.target.value })} onChange={(e) => onUpdateConfig({ filename: e.target.value })}
placeholder="invoice.pdf" placeholder="invoice.pdf"
className="bg-slate-800 text-xs" className="bg-muted text-xs"
/> />
</div> </div>
)} )}

View File

@ -1,11 +1,18 @@
"use client"; "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, * indicator, and the action buttons (Save, Activate/Pause, Delete,
* View runs, Back). * 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 * both views in FlowEditorShell. Without this, canvas users had no
* way to save without toggling to list view. * way to save without toggling to list view.
* *
@ -18,6 +25,7 @@
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { import {
ArrowLeft, ArrowLeft,
CircleDot,
History, History,
Loader2, Loader2,
PauseCircle, PauseCircle,
@ -27,9 +35,7 @@ import {
Workflow, Workflow,
} from "lucide-react"; } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
useFlowEditor, useFlowEditor,
@ -52,32 +58,33 @@ export function EditorHeader() {
} = useFlowEditor(); } = useFlowEditor();
return ( return (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-1.5 px-6 pt-5">
<div className="flex items-center gap-2 text-xs text-slate-500"> <div className="flex flex-wrap items-center gap-3">
{/* ---- left: back · icon · name · status · edited ---- */}
<button <button
type="button" type="button"
onClick={() => router.push("/flows")} 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" /> <ArrowLeft className="h-4 w-4" />
Flows
</button> </button>
</div> <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-soft text-primary">
<div className="flex flex-wrap items-start justify-between gap-3"> <Workflow className="h-[18px] w-[18px]" />
<div className="flex min-w-0 flex-1 items-center gap-3"> </span>
<Workflow className="h-5 w-5 shrink-0 text-primary" /> <input
<Input
value={state.name} value={state.name}
onChange={(e) => onChange={(e) => setState((s) => ({ ...s, name: e.target.value }))}
setState((s) => ({ ...s, name: e.target.value }))
}
placeholder="Flow name" 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 && ( {dirty && (
<span <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" title="Unsaved changes — hit Save to persist"
aria-live="polite" aria-live="polite"
> >
@ -85,8 +92,9 @@ export function EditorHeader() {
Edited Edited
</span> </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 <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@ -94,6 +102,9 @@ export function EditorHeader() {
> >
<History className="h-3.5 w-3.5" /> <History className="h-3.5 w-3.5" />
Runs 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>
<Button <Button
variant="ghost" variant="ghost"
@ -148,27 +159,47 @@ export function EditorHeader() {
</Button> </Button>
</div> </div>
</div> </div>
<Input
{/* ---- description note (subtle, inline-editable) ---- */}
<input
value={state.description} value={state.description}
onChange={(e) => onChange={(e) =>
setState((s) => ({ ...s, description: e.target.value })) setState((s) => ({ ...s, description: e.target.value }))
} }
placeholder="Optional description (internal — customers don't see this)" placeholder="Add a short description (internal — customers don't see this)"
className="bg-slate-900 text-sm" 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> </div>
); );
} }
function StatusBadge({ status }: { status: BuilderState["status"] }) { function StatusChip({ status }: { status: BuilderState["status"] }) {
const cls = { const cfg = {
draft: "border-slate-700 bg-slate-800 text-slate-300", draft: {
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300", // Neutral, not amber — amber is reserved for the adjacent
archived: "border-slate-700 bg-slate-800/50 text-slate-500", // "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]; }[status];
return ( return (
<Badge variant="outline" className={cn("shrink-0", cls)}> <span
{status.charAt(0).toUpperCase() + status.slice(1)} className={cn(
</Badge> "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, Tag,
UserPlus, UserPlus,
Workflow, Workflow,
} from "lucide-react"; } from 'lucide-react';
import { cn } from '@/lib/utils';
// ============================================================ // ============================================================
// Node-type union — single source of truth for every place the UI // Node-type union — single source of truth for every place the UI
@ -39,16 +41,16 @@ import {
// ============================================================ // ============================================================
export type NodeType = export type NodeType =
| "start" | 'start'
| "send_message" | 'send_message'
| "send_buttons" | 'send_buttons'
| "send_list" | 'send_list'
| "send_media" | 'send_media'
| "collect_input" | 'collect_input'
| "condition" | 'condition'
| "set_tag" | 'set_tag'
| "handoff" | 'handoff'
| "end"; | 'end';
export interface BuilderNode { export interface BuilderNode {
node_key: string; node_key: string;
@ -65,54 +67,212 @@ export interface BuilderNode {
// the user sees a node summary. // 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< export const NODE_META: Record<
NodeType, 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: { send_message: {
label: "Send message", label: 'Send message',
icon: MessageCircle, icon: MessageCircle,
color: "text-sky-400", color: 'text-sky-400',
blurb: 'Sends a WhatsApp text message',
category: 'messaging',
}, },
send_buttons: { send_buttons: {
label: "Send buttons", label: 'Send buttons',
icon: ListChecks, icon: ListChecks,
color: "text-primary", color: 'text-primary',
blurb: 'Sends quick-reply buttons',
category: 'messaging',
}, },
send_list: { send_list: {
label: "Send list", label: 'Send list',
icon: ListPlus, icon: ListPlus,
color: "text-indigo-400", color: 'text-indigo-400',
blurb: 'Sends a tappable list of options',
category: 'messaging',
}, },
send_media: { send_media: {
label: "Send media", label: 'Send media',
icon: Paperclip, icon: Paperclip,
color: "text-cyan-400", color: 'text-cyan-400',
blurb: 'Sends an image, video, or document',
category: 'messaging',
}, },
collect_input: { collect_input: {
label: "Collect input", label: 'Collect input',
icon: Inbox, icon: Inbox,
color: "text-teal-400", color: 'text-teal-400',
blurb: 'Asks a question, saves the reply',
category: 'logic',
}, },
condition: { condition: {
label: "If / else", label: 'If / else',
icon: GitFork, icon: GitFork,
color: "text-fuchsia-400", color: 'text-fuchsia-400',
blurb: 'Branches on a rule',
category: 'logic',
}, },
set_tag: { set_tag: {
label: "Tag contact", label: 'Tag contact',
icon: Tag, icon: Tag,
color: "text-pink-400", color: 'text-pink-400',
blurb: 'Adds or removes a contact tag',
category: 'logic',
}, },
handoff: { handoff: {
label: "Handoff to agent", label: 'Handoff to agent',
icon: UserPlus, 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. // Pure editing helpers — used by forms in both views.
// ============================================================ // ============================================================
@ -127,8 +287,8 @@ export function slugify(s: string, fallback: string): string {
const cleaned = s const cleaned = s
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/[^a-z0-9]+/g, "_") .replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, ""); .replace(/^_+|_+$/g, '');
return cleaned || fallback; return cleaned || fallback;
} }
@ -140,37 +300,39 @@ export function slugify(s: string, fallback: string): string {
// ============================================================ // ============================================================
export function truncate(s: string, max = 80): 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; 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 { export function summarizeNode(node: BuilderNode): string | null {
const cfg = node.config; const cfg = node.config;
switch (node.node_type) { switch (node.node_type) {
case "start": case 'start':
case "end": case 'end':
return null; return null;
case "send_message": { case 'send_message': {
const text = typeof cfg.text === "string" ? cfg.text : ""; const text = typeof cfg.text === 'string' ? cfg.text : '';
return text.length > 0 ? truncate(text) : null; return text.length > 0 ? truncate(text) : null;
} }
case "send_buttons": { case 'send_buttons': {
const text = typeof cfg.text === "string" ? cfg.text : ""; const text = typeof cfg.text === 'string' ? cfg.text : '';
const buttons = Array.isArray(cfg.buttons) const buttons = Array.isArray(cfg.buttons)
? (cfg.buttons as Array<Record<string, unknown>>) ? (cfg.buttons as Array<Record<string, unknown>>)
: []; : [];
const titles = buttons const titles = buttons
.map((b) => (typeof b.title === "string" ? b.title : "")) .map((b) => (typeof b.title === 'string' ? b.title : ''))
.filter(Boolean) .filter(Boolean)
.join(" / "); .join(' / ');
if (text.length > 0) { 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; return titles || null;
} }
case "send_list": { case 'send_list': {
const text = typeof cfg.text === "string" ? cfg.text : ""; const text = typeof cfg.text === 'string' ? cfg.text : '';
const sections = Array.isArray(cfg.sections) const sections = Array.isArray(cfg.sections)
? (cfg.sections as Array<Record<string, unknown>>) ? (cfg.sections as Array<Record<string, unknown>>)
: []; : [];
@ -180,75 +342,81 @@ export function summarizeNode(node: BuilderNode): string | null {
}, 0); }, 0);
if (text.length > 0) { if (text.length > 0) {
return rowCount > 0 return rowCount > 0
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? "" : "s"}` ? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? '' : 's'}`
: truncate(text); : truncate(text);
} }
return rowCount > 0 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; : null;
} }
case "send_media": { case 'send_media': {
const mediaType = const mediaType =
typeof cfg.media_type === "string" ? cfg.media_type : ""; typeof cfg.media_type === 'string' ? cfg.media_type : '';
const filename = typeof cfg.filename === "string" ? cfg.filename : ""; const filename = typeof cfg.filename === 'string' ? cfg.filename : '';
const url = typeof cfg.media_url === "string" ? cfg.media_url : ""; const url = typeof cfg.media_url === 'string' ? cfg.media_url : '';
const caption = typeof cfg.caption === "string" ? cfg.caption : ""; const caption = typeof cfg.caption === 'string' ? cfg.caption : '';
const label = mediaType const label = mediaType
? mediaType.charAt(0).toUpperCase() + mediaType.slice(1) ? mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
: "Media"; : 'Media';
if (!url) return `${label} (no file uploaded)`; if (!url) return `${label} (no file uploaded)`;
const name = filename || url.split("/").pop() || "file"; const name = filename || url.split('/').pop() || 'file';
return caption return caption
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}` ? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
: `${label}: ${truncate(name, 60)}`; : `${label}: ${truncate(name, 60)}`;
} }
case "collect_input": { case 'collect_input': {
const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : ""; const prompt = typeof cfg.prompt_text === 'string' ? cfg.prompt_text : '';
const varKey = typeof cfg.var_key === "string" ? cfg.var_key : ""; const varKey = typeof cfg.var_key === 'string' ? cfg.var_key : '';
if (prompt.length > 0) { 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; return varKey ? `→ vars.${varKey}` : null;
} }
case "condition": { case 'condition': {
const subjectKey = const subjectKey =
typeof cfg.subject_key === "string" ? cfg.subject_key : ""; typeof cfg.subject_key === 'string' ? cfg.subject_key : '';
if (!subjectKey) return null; if (!subjectKey) return null;
const subject = const subject =
cfg.subject === "tag" cfg.subject === 'tag'
? "tag" ? 'tag'
: cfg.subject === "contact_field" : cfg.subject === 'contact_field'
? "field" ? 'field'
: "var"; : 'var';
const subjectStr = const subjectStr =
subject === "tag" ? `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`; subject === 'tag'
? `has tag ${truncate(subjectKey, 24)}`
: `${subject}.${subjectKey}`;
const op = const op =
cfg.operator === "equals" cfg.operator === 'equals'
? "==" ? '=='
: cfg.operator === "contains" : cfg.operator === 'contains'
? "contains" ? 'contains'
: cfg.operator === "present" : cfg.operator === 'present'
? "exists" ? 'exists'
: cfg.operator === "absent" : cfg.operator === 'absent'
? "missing" ? 'missing'
: ""; : '';
const value = typeof cfg.value === "string" ? cfg.value : ""; const value = typeof cfg.value === 'string' ? cfg.value : '';
const valStr = const valStr =
(cfg.operator === "equals" || cfg.operator === "contains") && value (cfg.operator === 'equals' || cfg.operator === 'contains') && value
? ` "${truncate(value, 20)}"` ? ` "${truncate(value, 20)}"`
: ""; : '';
return subject === "tag" ? subjectStr : `${subjectStr} ${op}${valStr}`; return subject === 'tag' ? subjectStr : `${subjectStr} ${op}${valStr}`;
} }
case "set_tag": { case 'set_tag': {
const mode = cfg.mode === "remove" ? "Remove" : "Add"; const mode = cfg.mode === 'remove' ? 'Remove' : 'Add';
const tagId = typeof cfg.tag_id === "string" ? cfg.tag_id : ""; const tagId = typeof cfg.tag_id === 'string' ? cfg.tag_id : '';
// No tag name available without an async lookup here; show a // No tag name available without an async lookup here; show a
// short prefix of the UUID so users can disambiguate between // short prefix of the UUID so users can disambiguate between
// multiple set_tag nodes at a glance. // 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": { case 'handoff': {
const note = typeof cfg.note === "string" ? cfg.note : ""; const note = typeof cfg.note === 'string' ? cfg.note : '';
return note.length > 0 ? truncate(note) : null; 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 // sticky-positioned over scrolled-behind node cards (a translucent
// bg-emerald-500/10 would bleed through ugly). // bg-emerald-500/10 would bleed through ugly).
return ( 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" /> <CircleCheck className="h-4 w-4 shrink-0" />
No issues. Ready to activate. No issues. Ready to activate.
</div> </div>
@ -41,11 +41,11 @@ export function ValidationPanel() {
return ( return (
<div <div
className={cn( 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", 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 ? ( {errors.length > 0 ? (
<CircleAlert className="h-4 w-4 text-red-400" /> <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)} /> <CircleAlert className={cn("mt-0.5 h-3 w-3 shrink-0", iconTone)} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
{issue.node_key && ( {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} {issue.node_key}
</code> </code>
)} )}
@ -103,7 +103,7 @@ export function IssueLine({
type="button" type="button"
onClick={() => onJump(issue.node_key!)} onClick={() => onJump(issue.node_key!)}
className={cn( 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, tone,
)} )}
aria-label={`Jump to node ${issue.node_key}`} aria-label={`Jump to node ${issue.node_key}`}

View File

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

View File

@ -33,11 +33,14 @@ interface ConversationListProps {
const STATUS_COLORS: Record<ConversationStatus, string> = { const STATUS_COLORS: Record<ConversationStatus, string> = {
open: "bg-primary", open: "bg-primary",
pending: "bg-amber-500", 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: "All", value: "all" },
{ label: "Unread", value: "unread" },
{ label: "Open", value: "open" }, { label: "Open", value: "open" },
{ label: "Pending", value: "pending" }, { label: "Pending", value: "pending" },
{ label: "Closed", value: "closed" }, { label: "Closed", value: "closed" },
@ -51,7 +54,7 @@ export function ConversationList({
resyncToken = 0, resyncToken = 0,
}: ConversationListProps) { }: ConversationListProps) {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [filter, setFilter] = useState<ConversationStatus | "all">("all"); const [filter, setFilter] = useState<InboxFilter>("all");
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Keep the latest callback in a ref so the fetch effect below can // Keep the latest callback in a ref so the fetch effect below can
@ -110,7 +113,9 @@ export function ConversationList({
const filtered = useMemo(() => { const filtered = useMemo(() => {
let result = conversations; 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); 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 // 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 // the single pane showing; fixed 320px on desktop where it shares the
// row with the thread + contact sidebar. // 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 */} {/* 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"> <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 <Input
value={search} value={search}
onChange={handleSearchChange} onChange={handleSearchChange}
placeholder="Search conversations..." 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> </div>
<DropdownMenu> <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"} {activeFilter?.label ?? "All"}
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="start" align="start"
className="border-slate-700 bg-slate-800" className="border-border bg-popover"
> >
{FILTER_OPTIONS.map((opt) => ( {FILTER_OPTIONS.map((opt) => (
<DropdownMenuItem <DropdownMenuItem
@ -177,7 +182,7 @@ export function ConversationList({
"text-sm", "text-sm",
filter === opt.value filter === opt.value
? "text-primary" ? "text-primary"
: "text-slate-300" : "text-popover-foreground"
)} )}
> >
{opt.label} {opt.label}
@ -200,7 +205,7 @@ export function ConversationList({
</div> </div>
) : filtered.length === 0 ? ( ) : filtered.length === 0 ? (
<div className="px-4 py-12 text-center"> <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>
) : ( ) : (
<div className="flex flex-col"> <div className="flex flex-col">
@ -248,12 +253,12 @@ function ConversationItem({
<button <button
onClick={handleClick} onClick={handleClick}
className={cn( className={cn(
"flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-slate-800/50", "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-slate-800/70" isActive && "border-l-2 border-primary bg-muted/70"
)} )}
> >
{/* Avatar */} {/* 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 ? ( {contact?.avatar_url ? (
<img <img
src={contact.avatar_url} src={contact.avatar_url}
@ -268,13 +273,13 @@ function ConversationItem({
{/* Content */} {/* Content */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <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} {displayName}
</span> </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>
<div className="mt-0.5 flex items-center justify-between gap-2"> <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"} {conversation.last_message_text || "No messages yet"}
</p> </p>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">

View File

@ -95,7 +95,7 @@ export function MessageActions({
<div <div
data-touch-open={touchOpen || pickerOpen ? "true" : undefined} data-touch-open={touchOpen || pickerOpen ? "true" : undefined}
className={cn( 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", "opacity-0 group-hover/actions:opacity-100 group-focus-within/actions:opacity-100",
"data-[touch-open=true]:opacity-100", "data-[touch-open=true]:opacity-100",
isAgent ? "right-3" : "left-3", isAgent ? "right-3" : "left-3",
@ -103,7 +103,7 @@ export function MessageActions({
> >
<Popover open={pickerOpen} onOpenChange={setPickerOpen}> <Popover open={pickerOpen} onOpenChange={setPickerOpen}>
<PopoverTrigger <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" aria-label="React"
> >
<SmilePlus className="h-3.5 w-3.5" /> <SmilePlus className="h-3.5 w-3.5" />
@ -117,7 +117,7 @@ export function MessageActions({
key={e} key={e}
type="button" type="button"
onClick={() => handlePickEmoji(e)} 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}`} aria-label={`React with ${e}`}
> >
{e} {e}
@ -128,7 +128,7 @@ export function MessageActions({
<button <button
type="button" type="button"
onClick={handleReply} 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" aria-label="Reply"
> >
<CornerUpLeft className="h-3.5 w-3.5" /> <CornerUpLeft className="h-3.5 w-3.5" />
@ -136,7 +136,7 @@ export function MessageActions({
<button <button
type="button" type="button"
onClick={handleCopy} 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" aria-label="Copy"
> >
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />

View File

@ -30,11 +30,11 @@ interface MessageBubbleProps {
function StatusIcon({ status }: { status: Message["status"] }) { function StatusIcon({ status }: { status: Message["status"] }) {
switch (status) { switch (status) {
case "sending": case "sending":
return <Clock className="h-3 w-3 text-slate-400" />; return <Clock className="h-3 w-3 text-muted-foreground" />;
case "sent": case "sent":
return <Check className="h-3 w-3 text-slate-400" />; return <Check className="h-3 w-3 text-muted-foreground" />;
case "delivered": case "delivered":
return <CheckCheck className="h-3 w-3 text-slate-400" />; return <CheckCheck className="h-3 w-3 text-muted-foreground" />;
case "read": case "read":
return <CheckCheck className="h-3 w-3 text-blue-400" />; return <CheckCheck className="h-3 w-3 text-blue-400" />;
case "failed": case "failed":
@ -46,8 +46,8 @@ function StatusIcon({ status }: { status: Message["status"] }) {
function MediaUnavailable({ label }: { label: string }) { function MediaUnavailable({ label }: { label: string }) {
return ( return (
<div className="flex items-center gap-2 rounded-lg bg-slate-700/40 px-3 py-2 text-xs text-slate-300"> <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-slate-500" /> <ImageOff className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{label} unavailable</span> <span>{label} unavailable</span>
</div> </div>
); );
@ -92,15 +92,15 @@ function MediaImage({ url, alt }: { url: string; alt: string }) {
if (error) { if (error) {
return ( 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">
<ImageOff className="h-8 w-8 text-slate-500" /> <ImageOff className="h-8 w-8 text-muted-foreground" />
</div> </div>
); );
} }
if (loading) { if (loading) {
return ( 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 className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div> </div>
); );
@ -181,9 +181,9 @@ function MessageContent({ message }: { message: Message }) {
href={message.media_url} href={message.media_url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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"> <span className="truncate">
{message.content_text || "Document"} {message.content_text || "Document"}
</span> </span>
@ -208,7 +208,7 @@ function MessageContent({ message }: { message: Message }) {
case "location": case "location":
return ( return (
<div className="flex items-center gap-2 text-sm"> <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> <span>{message.content_text || "Location shared"}</span>
</div> </div>
); );
@ -221,7 +221,7 @@ function MessageContent({ message }: { message: Message }) {
// tap rather than the customer typing the same words. // tap rather than the customer typing the same words.
return ( return (
<div className="flex flex-col gap-0.5"> <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" /> <CornerDownLeft className="h-3 w-3" />
Button reply Button reply
</span> </span>
@ -265,11 +265,15 @@ export function MessageBubble({
"relative rounded-2xl px-3 py-2", "relative rounded-2xl px-3 py-2",
isAgent isAgent
? "rounded-br-md bg-primary text-primary-foreground" ? "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 && ( {reply && (
<ReplyQuote authorLabel={reply.authorLabel} preview={reply.preview} /> <ReplyQuote
authorLabel={reply.authorLabel}
preview={reply.preview}
onPrimary={isAgent}
/>
)} )}
<MessageContent message={message} /> <MessageContent message={message} />
<div <div
@ -278,7 +282,18 @@ export function MessageBubble({
isAgent ? "justify-end" : "justify-start", 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} />} {isAgent && <StatusIcon status={message.status} />}
</div> </div>
</div> </div>

View File

@ -1,13 +1,68 @@
"use client"; "use client";
import { useState, useRef, useCallback, KeyboardEvent } from "react"; import {
import { Send, LayoutTemplate } from "lucide-react"; 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 { Button } from "@/components/ui/button";
import { GatedButton } from "@/components/ui/gated-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 { useCan } from "@/hooks/use-can";
import { cn } from "@/lib/utils"; 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"; 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 { interface ReplyDraft {
/** Internal UUID of the message being replied to — sent back through onSend. */ /** Internal UUID of the message being replied to — sent back through onSend. */
id: string; id: string;
@ -15,19 +70,52 @@ interface ReplyDraft {
preview: string; 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 { interface MessageComposerProps {
conversationId: string; conversationId: string;
sessionExpired: boolean; sessionExpired: boolean;
onSend: (text: string, replyToId?: string) => void; onSend: (text: string, replyToId?: string) => void;
onSendMedia: (payload: SendMediaPayload) => void;
onOpenTemplates: () => void; onOpenTemplates: () => void;
replyTo?: ReplyDraft | null; replyTo?: ReplyDraft | null;
onClearReply?: () => void; 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({ export function MessageComposer({
conversationId, conversationId,
sessionExpired, sessionExpired,
onSend, onSend,
onSendMedia,
onOpenTemplates, onOpenTemplates,
replyTo, replyTo,
onClearReply, onClearReply,
@ -35,11 +123,63 @@ export function MessageComposer({
const [text, setText] = useState(""); const [text, setText] = useState("");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null); 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. // Viewers (read-only role) can browse the inbox but never send.
// For solo users this is always true — single-owner accounts pass // For solo users this is always true — single-owner accounts pass
// every capability — so the disabled branch is a no-op there. // every capability — so the disabled branch is a no-op there.
const canSend = useCan("send-messages"); const canSend = useCan("send-messages");
const readOnly = !canSend; 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 adjustHeight = useCallback(() => {
const el = textareaRef.current; const el = textareaRef.current;
@ -83,8 +223,162 @@ export function MessageComposer({
[adjustHeight] [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 ( return (
<div className="border-t border-slate-800 bg-slate-900 p-3"> <div className="border-t border-border bg-card p-3">
{replyTo && ( {replyTo && (
<div className="mb-2"> <div className="mb-2">
<ReplyQuote <ReplyQuote
@ -111,14 +405,119 @@ export function MessageComposer({
</div> </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"> <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 <GatedButton
variant="ghost" variant="ghost"
size="sm" size="sm"
canAct={!readOnly} canAct={!readOnly}
gateReason="send messages" gateReason="send messages"
title={readOnly ? undefined : "Send template"} 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} onClick={onOpenTemplates}
> >
<LayoutTemplate className="h-4 w-4" /> <LayoutTemplate className="h-4 w-4" />
@ -143,7 +542,7 @@ export function MessageComposer({
// The placeholder text also surfaces the read-only state. // The placeholder text also surfaces the read-only state.
title={readOnly ? "Read-only — your role can't send messages" : undefined} title={readOnly ? "Read-only — your role can't send messages" : undefined}
className={cn( 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" (sessionExpired || readOnly) && "cursor-not-allowed opacity-50"
)} )}
/> />
@ -159,13 +558,106 @@ export function MessageComposer({
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
</GatedButton> </GatedButton>
</div> </div>
)}
{/* Hint sits outside the flex row so its height doesn't push {/* Hint sits outside the flex row so its height doesn't push
`items-end` buttons below the textarea. Indented to line up `items-end` buttons below the textarea. Indented to line up
under the textarea left edge (w-9 button + gap-2 = 44px). */} under the textarea left edge. */}
<p className="mt-1 pl-11 text-[10px] text-slate-600"> {!draft && !recording && (
<p className="mt-1 pl-[5.5rem] text-[10px] text-muted-foreground">
Type &apos;/&apos; for quick replies Type &apos;/&apos; for quick replies
</p> </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> </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", "inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[11px] leading-none transition-colors",
g.byCurrentUser g.byCurrentUser
? "border-primary/60 bg-primary/15 text-primary hover:bg-primary/25" ? "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> <span className="text-sm leading-none">{g.emoji}</span>

View File

@ -3,6 +3,9 @@
import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createClient } from "@/lib/supabase/client"; import { createClient } from "@/lib/supabase/client";
import { useAuth } from "@/hooks/use-auth"; 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 { cn } from "@/lib/utils";
import type { import type {
Conversation, Conversation,
@ -21,6 +24,8 @@ import {
Clock, Clock,
ArrowLeft, ArrowLeft,
RefreshCw, RefreshCw,
PanelRightOpen,
PanelRightClose,
} from "lucide-react"; } from "lucide-react";
import { format, isToday, isYesterday, differenceInHours } from "date-fns"; import { format, isToday, isYesterday, differenceInHours } from "date-fns";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
@ -34,7 +39,12 @@ import {
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { MessageBubble } from "./message-bubble"; import { MessageBubble } from "./message-bubble";
import { MessageActions } from "./message-actions"; 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 { TemplatePicker } from "./template-picker";
import { buildReplyPreview } from "./reply-quote"; import { buildReplyPreview } from "./reply-quote";
import { toast } from "sonner"; import { toast } from "sonner";
@ -87,6 +97,15 @@ interface MessageThreadProps {
* working; the button is only rendered when this is provided. * working; the button is only rendered when this is provided.
*/ */
onRefresh?: () => void; 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 { function formatDateSeparator(dateStr: string): string {
@ -116,7 +135,7 @@ function groupMessagesByDate(messages: Message[]) {
const STATUS_OPTIONS: { label: string; value: ConversationStatus; color: string }[] = [ const STATUS_OPTIONS: { label: string; value: ConversationStatus; color: string }[] = [
{ label: "Open", value: "open", color: "text-primary" }, { label: "Open", value: "open", color: "text-primary" },
{ label: "Pending", value: "pending", color: "text-amber-400" }, { 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. * if we ever switch the asset, both spots update together.
*/ */
const DOODLE_BG_CLASSES = 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({ export function MessageThread({
conversation, conversation,
@ -143,8 +162,11 @@ export function MessageThread({
onBack, onBack,
resyncToken = 0, resyncToken = 0,
onRefresh, onRefresh,
contactPanelOpen,
onToggleContactPanel,
}: MessageThreadProps) { }: MessageThreadProps) {
const { user } = useAuth(); const { user } = useAuth();
const { getPresence, getRow, now } = usePresence();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const [templateModalOpen, setTemplateModalOpen] = useState(false); const [templateModalOpen, setTemplateModalOpen] = useState(false);
@ -473,6 +495,72 @@ export function MessageThread({
[conversation, onNewMessage, onUpdateMessage] [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( const handleStatusChange = useCallback(
async (status: ConversationStatus) => { async (status: ConversationStatus) => {
if (!conversation) return; if (!conversation) return;
@ -696,13 +784,13 @@ export function MessageThread({
if (!conversation || !contact) { if (!conversation || !contact) {
return ( return (
<div className={cn("flex flex-1 flex-col items-center justify-center", DOODLE_BG_CLASSES)}> <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"> <div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<MessageSquare className="h-8 w-8 text-slate-600" /> <MessageSquare className="h-8 w-8 text-muted-foreground" />
</div> </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 Select a conversation
</h3> </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 Choose a conversation from the left to start messaging
</p> </p>
</div> </div>
@ -721,10 +809,18 @@ export function MessageThread({
: "Assign"; : "Assign";
return ( return (
<div className={cn("flex flex-1 flex-col", DOODLE_BG_CLASSES)}> // `min-w-0` is load-bearing: the page already puts min-w-0 on the
{/* Header — solid bg-slate-900 sits on top of the doodle so 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. */} 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"> <div className="flex min-w-0 items-center gap-2 sm:gap-3">
{/* Back-to-list button — mobile only. Hidden on lg+ where the {/* Back-to-list button — mobile only. Hidden on lg+ where the
conversation list is always visible next to the thread. */} conversation list is always visible next to the thread. */}
@ -733,24 +829,24 @@ export function MessageThread({
type="button" type="button"
onClick={onBack} onClick={onBack}
aria-label="Back to conversations" 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" /> <ArrowLeft className="h-5 w-5" />
</button> </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()} {displayName.charAt(0).toUpperCase()}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h2 className="truncate text-sm font-semibold text-white">{displayName}</h2> <h2 className="truncate text-sm font-semibold text-foreground">{displayName}</h2>
<p className="truncate text-xs text-slate-400">{contact.phone}</p> <p className="truncate text-xs text-muted-foreground">{contact.phone}</p>
</div> </div>
{/* Session timer badge — hidden on the narrowest phones so {/* Session timer badge — hidden on the narrowest phones so
the name + back arrow keep their room. */} the name + back arrow keep their room. */}
<Badge <Badge
variant="outline" variant="outline"
className={cn( 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" sessionInfo.expired ? "text-red-400" : "text-primary"
)} )}
> >
@ -760,6 +856,33 @@ export function MessageThread({
</div> </div>
<div className="flex items-center gap-2"> <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 {/* Manual refresh — forces a refetch of the messages + the
conversation list (the parent bumps its resyncToken). Useful conversation list (the parent bumps its resyncToken). Useful
when realtime missed an event or the agent just wants to be when realtime missed an event or the agent just wants to be
@ -773,7 +896,7 @@ export function MessageThread({
aria-label="Refresh conversation" aria-label="Refresh conversation"
title="Refresh" title="Refresh"
className={cn( 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 <RefreshCw
@ -785,15 +908,15 @@ export function MessageThread({
{/* Status dropdown */} {/* Status dropdown */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger className={cn( <DropdownMenuTrigger className={cn(
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-slate-800", "inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
currentStatus?.color ?? "text-slate-400" currentStatus?.color ?? "text-muted-foreground"
)}> )}>
{currentStatus?.label ?? "Status"} {currentStatus?.label ?? "Status"}
<ChevronDown className="h-3 w-3" /> <ChevronDown className="h-3 w-3" />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
className="border-slate-700 bg-slate-800" className="border-border bg-popover"
> >
{STATUS_OPTIONS.map((opt) => ( {STATUS_OPTIONS.map((opt) => (
<DropdownMenuItem <DropdownMenuItem
@ -811,8 +934,8 @@ export function MessageThread({
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
className={cn( className={cn(
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-slate-800", "inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
assignedAgentId ? "text-primary" : "text-slate-400" assignedAgentId ? "text-primary" : "text-muted-foreground"
)} )}
> >
<UserPlus className="h-3 w-3" /> <UserPlus className="h-3 w-3" />
@ -821,24 +944,34 @@ export function MessageThread({
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
className="border-slate-700 bg-slate-800" className="border-border bg-popover"
> >
{profiles.length === 0 ? ( {profiles.length === 0 ? (
<DropdownMenuItem disabled className="text-sm text-slate-500"> <DropdownMenuItem disabled className="text-sm text-muted-foreground">
No teammates available No teammates available
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
profiles.map((p) => { profiles.map((p) => {
const isSelected = p.user_id === assignedAgentId; const isSelected = p.user_id === assignedAgentId;
const presence = getPresence(p.user_id);
return ( return (
<DropdownMenuItem <DropdownMenuItem
key={p.id} key={p.id}
onClick={() => handleAssignChange(p.user_id)} onClick={() => handleAssignChange(p.user_id)}
className={cn( className={cn(
"text-sm", "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"> <span className="flex-1">
{p.full_name} {p.full_name}
{p.user_id === user?.id ? " (me)" : ""} {p.user_id === user?.id ? " (me)" : ""}
@ -850,10 +983,10 @@ export function MessageThread({
)} )}
{assignedAgentId && ( {assignedAgentId && (
<> <>
<DropdownMenuSeparator className="bg-slate-700" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleAssignChange(null)} onClick={() => handleAssignChange(null)}
className="text-sm text-slate-400" className="text-sm text-muted-foreground"
> >
Unassign Unassign
</DropdownMenuItem> </DropdownMenuItem>
@ -872,8 +1005,8 @@ export function MessageThread({
</div> </div>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12"> <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-sm text-muted-foreground">No messages yet</p>
<p className="text-xs text-slate-600"> <p className="text-xs text-muted-foreground">
Send a template to start the conversation Send a template to start the conversation
</p> </p>
</div> </div>
@ -883,7 +1016,7 @@ export function MessageThread({
<div key={group.date}> <div key={group.date}>
{/* Date separator */} {/* Date separator */}
<div className="mb-4 flex items-center justify-center"> <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)} {formatDateSeparator(group.date)}
</span> </span>
</div> </div>
@ -942,6 +1075,7 @@ export function MessageThread({
conversationId={conversation.id} conversationId={conversation.id}
sessionExpired={sessionInfo.expired} sessionExpired={sessionInfo.expired}
onSend={handleSend} onSend={handleSend}
onSendMedia={handleSendMedia}
onOpenTemplates={handleOpenTemplates} onOpenTemplates={handleOpenTemplates}
replyTo={replyTo} replyTo={replyTo}
onClearReply={() => setReplyTo(null)} onClearReply={() => setReplyTo(null)}

View File

@ -14,25 +14,38 @@ interface ReplyQuoteProps {
/** Present → renders the composer-chip variant with an X button. Absent → /** Present → renders the composer-chip variant with an X button. Absent →
* renders the embedded-in-bubble variant. */ * renders the embedded-in-bubble variant. */
onDismiss?: () => void; 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({ export function ReplyQuote({
authorLabel, authorLabel,
preview, preview,
onDismiss, onDismiss,
onPrimary = false,
}: ReplyQuoteProps) { }: ReplyQuoteProps) {
const isChip = !!onDismiss; const isChip = !!onDismiss;
return ( return (
<div <div
className={cn( 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 isChip
? "rounded-md bg-slate-800/80" ? "rounded-md bg-muted/80"
: "mb-1.5 rounded-md bg-black/20", : 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="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} {authorLabel}
</div> </div>
{/* Wrap the preview instead of truncating to a single line. {/* 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. * layout wider, shoving the contact sidebar off-screen.
* `break-words` also wraps long URLs that have no whitespace * `break-words` also wraps long URLs that have no whitespace
* to break on. Issue #165. */} * 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} {preview}
</div> </div>
</div> </div>
@ -51,7 +64,7 @@ export function ReplyQuote({
type="button" type="button"
onClick={onDismiss} onClick={onDismiss}
aria-label="Cancel reply" 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" /> <X className="h-3.5 w-3.5" />
</button> </button>

View File

@ -104,10 +104,13 @@ export function TemplatePicker({
return; 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 const { data, error } = await supabase
.from("message_templates") .from("message_templates")
.select("*") .select("*")
.eq("user_id", user.id)
.eq("status", "APPROVED") .eq("status", "APPROVED")
.order("created_at", { ascending: false }); .order("created_at", { ascending: false });
@ -183,13 +186,13 @@ export function TemplatePicker({
return ( return (
<Dialog open={open} onOpenChange={handleOpenChange}> <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> <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" /> <LayoutTemplate className="h-4 w-4 text-primary" />
{selected ? selected.name : "Send template"} {selected ? selected.name : "Send template"}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-muted-foreground">
{selected {selected
? "Fill in the placeholders to render this template. Meta requires every variable to be set." ? "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."} : "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" /> <Loader2 className="h-5 w-5 animate-spin text-primary" />
</div> </div>
) : templates.length === 0 ? ( ) : templates.length === 0 ? (
<div className="rounded-md border border-slate-800 bg-slate-950/50 p-6 text-center"> <div className="rounded-md border border-border bg-background/50 p-6 text-center">
<p className="text-sm text-slate-300">No approved templates</p> <p className="text-sm text-popover-foreground">No approved templates</p>
<p className="mt-1 text-xs text-slate-500"> <p className="mt-1 text-xs text-muted-foreground">
Approve a template in Meta WhatsApp Manager, then sync it Approve a template in Meta WhatsApp Manager, then sync it
from Settings → Templates. from Settings → Templates.
</p> </p>
@ -216,28 +219,28 @@ export function TemplatePicker({
key={t.id} key={t.id}
type="button" type="button"
onClick={() => pickTemplate(t)} 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="flex items-start gap-2">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <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} {t.name}
</p> </p>
<Badge className="border border-primary/30 bg-primary/20 text-[10px] text-primary"> <Badge className="border border-primary/30 bg-primary/20 text-[10px] text-primary">
{t.category} {t.category}
</Badge> </Badge>
{t.language && ( {t.language && (
<span className="text-[10px] uppercase text-slate-500"> <span className="text-[10px] uppercase text-muted-foreground">
{t.language} {t.language}
</span> </span>
)} )}
</div> </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} {t.body_text}
</p> </p>
</div> </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> </div>
</button> </button>
)) ))
@ -245,33 +248,33 @@ export function TemplatePicker({
</div> </div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-md border border-slate-800 bg-slate-950/50 p-3"> <div className="rounded-md border border-border bg-background/50 p-3">
<p className="mb-1 text-xs text-slate-400">Preview</p> <p className="mb-1 text-xs text-muted-foreground">Preview</p>
<p className="whitespace-pre-wrap text-sm text-slate-200"> <p className="whitespace-pre-wrap text-sm text-popover-foreground">
{renderBodyPreview(selected.body_text, params)} {renderBodyPreview(selected.body_text, params)}
</p> </p>
{selected.footer_text && ( {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} {selected.footer_text}
</p> </p>
)} )}
</div> </div>
{slots && slots.headerVarCount > 0 && ( {slots && slots.headerVarCount > 0 && (
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-slate-300"> <Label className="text-xs text-popover-foreground">
{`Header {{1}}`} {`Header {{1}}`}
</Label> </Label>
<Input <Input
value={headerText} value={headerText}
onChange={(e) => setHeaderText(e.target.value)} onChange={(e) => setHeaderText(e.target.value)}
placeholder="Value for the header variable" 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> </div>
)} )}
{slots?.bodyVars.map((v, i) => ( {slots?.bodyVars.map((v, i) => (
<div key={v} className="space-y-1"> <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 <Input
value={params[i] ?? ""} value={params[i] ?? ""}
onChange={(e) => { onChange={(e) => {
@ -280,13 +283,13 @@ export function TemplatePicker({
setParams(next); setParams(next);
}} }}
placeholder={`Value for {{${v}}}`} 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> </div>
))} ))}
{slots?.urlButtonSlots.map((slot) => ( {slots?.urlButtonSlots.map((slot) => (
<div key={slot.index} className="space-y-1"> <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}}`} {`URL button "${slot.text}" — value for `}{`{{1}}`}
</Label> </Label>
<Input <Input
@ -298,9 +301,9 @@ export function TemplatePicker({
})) }))
} }
placeholder="URL suffix value" 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}}")} Final URL: {slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}")}
</p> </p>
</div> </div>
@ -314,7 +317,7 @@ export function TemplatePicker({
<Button <Button
variant="outline" variant="outline"
onClick={resetSelection} 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" /> <ArrowLeft className="h-4 w-4" />
Back Back
@ -331,7 +334,7 @@ export function TemplatePicker({
<Button <Button
variant="outline" variant="outline"
onClick={() => handleOpenChange(false)} onClick={() => handleOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800" className="border-border text-popover-foreground hover:bg-muted"
> >
Cancel Cancel
</Button> </Button>

View File

@ -16,6 +16,7 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ModeToggle } from "@/components/layout/mode-toggle";
const pageTitles: Record<string, string> = { const pageTitles: Record<string, string> = {
"/dashboard": "Dashboard", "/dashboard": "Dashboard",
@ -53,25 +54,28 @@ export function Header({ onOpenSidebar }: HeaderProps) {
"U"; "U";
return ( 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"> <div className="flex min-w-0 items-center gap-2">
{/* Hamburger — mobile only. 44×44 hit target per Apple HIG. */} {/* Hamburger — mobile only. 44×44 hit target per Apple HIG. */}
<button <button
type="button" type="button"
onClick={onOpenSidebar} onClick={onOpenSidebar}
aria-label="Open menu" 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" /> <Menu className="h-5 w-5" />
</button> </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} {title}
</h1> </h1>
</div> </div>
<div className="flex items-center gap-1 sm:gap-2">
<ModeToggle />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <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" aria-label="Open account menu"
> >
<Avatar className="size-8"> <Avatar className="size-8">
@ -85,29 +89,29 @@ export function Header({ onOpenSidebar }: HeaderProps) {
{initial} {initial}
</AvatarFallback> </AvatarFallback>
</Avatar> </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"} {profile?.full_name ?? "User"}
</span> </span>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
align="end" align="end"
sideOffset={6} 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"> <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"} {profile?.full_name ?? "User"}
</p> </p>
<p className="truncate text-xs text-slate-400"> <p className="truncate text-xs text-muted-foreground">
{profile?.email ?? ""} {profile?.email ?? ""}
</p> </p>
</div> </div>
<DropdownMenuSeparator className="bg-slate-800" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
render={ render={
<Link <Link
href="/settings?tab=profile" 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={ render={
<Link <Link
href="/settings?tab=whatsapp" 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" /> <SettingsIcon className="size-4" />
Settings Settings
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-800" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
onClick={signOut} 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" /> <LogOut className="size-4" />
Sign out Sign out
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
</header> </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", label: "Agent",
// Neutral slate: the operational default. // Neutral slate: the operational default.
className: className:
"border-slate-700 bg-slate-800 text-slate-300", "border-border bg-muted text-foreground",
}, },
viewer: { viewer: {
icon: User, icon: User,
label: "Viewer", label: "Viewer",
// Muted slate: read-only role; visually quieter than agent. // Muted slate: read-only role; visually quieter than agent.
className: className:
"border-slate-800 bg-slate-900 text-slate-500", "border-border bg-card text-muted-foreground",
}, },
}; };
import { import {
@ -161,7 +161,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
aria-label="Close menu" aria-label="Close menu"
onClick={onClose} onClick={onClose}
className={cn( 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 open
? "pointer-events-auto opacity-100" ? "pointer-events-auto opacity-100"
: "pointer-events-none opacity-0", : "pointer-events-none opacity-0",
@ -171,7 +171,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
<aside <aside
className={cn( className={cn(
// Mobile: fixed drawer that slides in from the left. // 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", "transition-transform duration-200 ease-out will-change-transform",
open ? "translate-x-0" : "-translate-x-full", open ? "translate-x-0" : "-translate-x-full",
// Desktop: static, always visible — reset all the mobile framing. // 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 {/* Logo row. On mobile we put a close button here; on desktop the
close button is hidden since the sidebar is always-visible. */} 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"> <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"> <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<MessageSquare className="h-4 w-4" /> <MessageSquare className="h-4 w-4" />
</div> </div>
<span className="text-sm font-semibold text-white"> <span className="text-sm font-semibold text-foreground">
CRM Template for WhatsApp CRM Template for WhatsApp
</span> </span>
</Link> </Link>
@ -194,7 +194,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close menu" 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" /> <X className="h-5 w-5" />
</button> </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", "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive isActive
? "bg-primary/10 text-primary" ? "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" /> <item.icon className="h-4 w-4" />
@ -263,7 +263,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
})} })}
</ul> </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"> <ul className="flex flex-col gap-1">
{bottomNavItems.map((item) => { {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", "flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
isActive isActive
? "bg-primary/10 text-primary" ? "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" /> <item.icon className="h-4 w-4" />
@ -289,7 +289,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
</nav> </nav>
{/* User section */} {/* 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 {/* Account name display — surfaced only when the account
name differs from the user's own name (see name differs from the user's own name (see
`showAccountStrip`). For a default solo account the two `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 below; for renamed or shared accounts it tells the user
which account they're acting in. */} which account they're acting in. */}
{showAccountStrip && account?.name ? ( {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" /> <UsersRound className="size-3.5 shrink-0" />
{/* `title=` exposes the full name on hover when it {/* `title=` exposes the full name on hover when it
gets truncated (long account names + narrow gets truncated (long account names + narrow
@ -326,7 +326,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
</div> </div>
) : null} ) : null}
<DropdownMenu> <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"> <Avatar className="size-8 shrink-0">
{profile?.avatar_url ? ( {profile?.avatar_url ? (
<AvatarImage <AvatarImage
@ -341,10 +341,10 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0 flex-1"> <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"} {profile?.full_name ?? "User"}
</p> </p>
<p className="truncate text-xs text-slate-400"> <p className="truncate text-xs text-muted-foreground">
{profile?.email ?? ""} {profile?.email ?? ""}
</p> </p>
</div> </div>
@ -353,14 +353,14 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
align="end" align="end"
side="top" side="top"
sideOffset={6} 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 <DropdownMenuItem
render={ render={
<Link <Link
href="/settings?tab=profile" href="/settings?tab=profile"
onClick={onClose} 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 <Link
href="/settings?tab=whatsapp" href="/settings?tab=whatsapp"
onClick={onClose} 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 className="size-4" />
Settings Settings
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-800" /> <DropdownMenuSeparator className="bg-border" />
<DropdownMenuItem <DropdownMenuItem
onClick={signOut} 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" /> <LogOut className="size-4" />
Sign out Sign out

View File

@ -39,10 +39,10 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
e.stopPropagation(); e.stopPropagation();
onEdit(deal); 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 isOverlay
? "shadow-xl" ? "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 */} {/* 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"> <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} {deal.title}
</h4> </h4>
{deal.status === "won" && ( {deal.status === "won" && (
@ -72,10 +72,10 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
{/* Contact row */} {/* Contact row */}
<div className="mt-2 flex items-center gap-2"> <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)} {initials(deal.contact?.name, deal.contact?.phone)}
</span> </span>
<span className="truncate text-xs text-slate-400">{contactLabel}</span> <span className="truncate text-xs text-muted-foreground">{contactLabel}</span>
</div> </div>
<div className="mt-2 flex items-center justify-between"> <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)} {formatCurrency(deal.value, deal.currency)}
</span> </span>
{deal.expected_close_date && ( {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" /> <Calendar className="h-3 w-3" />
{formatDate(deal.expected_close_date)} {formatDate(deal.expected_close_date)}
</span> </span>

View File

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

View File

@ -93,9 +93,9 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
return ( return (
<TooltipProvider> <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 <Metric
icon={<BarChart3 className="h-4 w-4 text-slate-400" />} icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
label="Total Deals" label="Total Deals"
value={String(stats.totalCount)} value={String(stats.totalCount)}
tooltip="Count of every deal in this pipeline that isn't marked as Lost. Won deals are still included." 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; tooltip: string;
}) { }) {
return ( return (
<div className="rounded-lg bg-slate-800/50 p-3"> <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-slate-400"> <div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{icon} {icon}
<span>{label}</span> <span>{label}</span>
<Tooltip> <Tooltip>
@ -157,7 +157,7 @@ function Metric({
<button <button
type="button" type="button"
aria-label={`How ${label} is calculated`} 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> </TooltipContent>
</Tooltip> </Tooltip>
</div> </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> </div>
); );
} }

View File

@ -164,7 +164,7 @@ export function PipelineBoard({
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
.pipeline-scroll { .pipeline-scroll {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgb(51 65 85) transparent; scrollbar-color: var(--border) transparent;
} }
.pipeline-scroll::-webkit-scrollbar { .pipeline-scroll::-webkit-scrollbar {
height: 8px; height: 8px;
@ -173,11 +173,11 @@ export function PipelineBoard({
background: transparent; background: transparent;
} }
.pipeline-scroll::-webkit-scrollbar-thumb { .pipeline-scroll::-webkit-scrollbar-thumb {
background-color: rgb(51 65 85); background-color: var(--border);
border-radius: 9999px; border-radius: 9999px;
} }
.pipeline-scroll::-webkit-scrollbar-thumb:hover { .pipeline-scroll::-webkit-scrollbar-thumb:hover {
background-color: rgb(71 85 105); background-color: var(--muted-foreground);
} }
} }
`}</style> `}</style>
@ -209,21 +209,21 @@ function StageColumn({
// restore the flex-1 share-the-row behavior. The droppable ref is // restore the flex-1 share-the-row behavior. The droppable ref is
// on the inner messages region below — intentionally NOT here, so // on the inner messages region below — intentionally NOT here, so
// a drag over the column header doesn't highlight the whole column. // 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 */} {/* 3px colored top border — sits above the column's padding */}
<div <div
className="-mx-4 -mt-4 h-[3px] rounded-t-xl" className="-mx-4 -mt-4 h-[3px] rounded-t-xl"
style={{ backgroundColor: stage.color }} style={{ backgroundColor: stage.color }}
/> />
<div className="flex items-center justify-between pt-3"> <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} {stage.name}
</h3> </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} {deals.length}
</span> </span>
</div> </div>
<p className="text-xs text-slate-400"> <p className="text-xs text-muted-foreground">
{formatCurrency(totalValue, currency)} {formatCurrency(totalValue, currency)}
</p> </p>
@ -236,7 +236,7 @@ function StageColumn({
}`} }`}
> >
{deals.length === 0 ? ( {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 Drop a deal here
</div> </div>
) : ( ) : (
@ -255,7 +255,7 @@ function StageColumn({
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onAddDeal(stage.id)} 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" /> <Plus className="mr-1 h-3 w-3" />
Add Deal Add Deal

View File

@ -199,9 +199,9 @@ export function PipelineSettings({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <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> <DialogHeader>
<DialogTitle className="text-white">Manage Pipeline</DialogTitle> <DialogTitle className="text-popover-foreground">Manage Pipeline</DialogTitle>
</DialogHeader> </DialogHeader>
{showDeleteConfirm ? ( {showDeleteConfirm ? (
@ -212,7 +212,7 @@ export function PipelineSettings({
<p className="text-sm font-medium text-red-400"> <p className="text-sm font-medium text-red-400">
Delete Pipeline Delete Pipeline
</p> </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 This will archive all deals in this pipeline. This cannot be
undone. undone.
</p> </p>
@ -222,7 +222,7 @@ export function PipelineSettings({
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowDeleteConfirm(false)} 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 Cancel
</Button> </Button>
@ -239,16 +239,16 @@ export function PipelineSettings({
<> <>
<div className="grid gap-4 py-2"> <div className="grid gap-4 py-2">
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-slate-300">Pipeline Name</Label> <Label className="text-muted-foreground">Pipeline Name</Label>
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="border-slate-700 bg-slate-800 text-white" className="border-border bg-muted text-foreground"
/> />
</div> </div>
<div className="grid gap-2"> <div className="grid gap-2">
<Label className="text-slate-300">Stages</Label> <Label className="text-muted-foreground">Stages</Label>
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@ -292,7 +292,9 @@ export function PipelineSettings({
style={{ style={{
backgroundColor: color, backgroundColor: color,
borderColor: borderColor:
newStageColor === color ? "white" : "transparent", newStageColor === color
? "var(--foreground)"
: "transparent",
}} }}
aria-label={`Pick color ${color}`} aria-label={`Pick color ${color}`}
/> />
@ -303,7 +305,7 @@ export function PipelineSettings({
value={newStageName} value={newStageName}
onChange={(e) => setNewStageName(e.target.value)} onChange={(e) => setNewStageName(e.target.value)}
placeholder="New stage name" 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) => { onKeyDown={(e) => {
if (e.key === "Enter") handleAddStage(); if (e.key === "Enter") handleAddStage();
}} }}
@ -313,7 +315,7 @@ export function PipelineSettings({
size="sm" size="sm"
onClick={handleAddStage} onClick={handleAddStage}
disabled={!newStageName.trim()} 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" /> <Plus className="mr-1 h-3 w-3" />
Add Add
@ -324,14 +326,14 @@ export function PipelineSettings({
<Button <Button
variant="outline" variant="outline"
onClick={onCreateNewPipeline} 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" /> <Plus className="mr-1 h-3 w-3" />
Create a new pipeline Create a new pipeline
</Button> </Button>
</div> </div>
<DialogFooter className="border-slate-700 bg-slate-900/50"> <DialogFooter className="border-border bg-popover/50">
<Button <Button
variant="destructive" variant="destructive"
onClick={() => setShowDeleteConfirm(true)} onClick={() => setShowDeleteConfirm(true)}
@ -342,7 +344,7 @@ export function PipelineSettings({
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} 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 Cancel
</Button> </Button>
@ -387,13 +389,13 @@ function SortableStageRow({
<div <div
ref={setNodeRef} ref={setNodeRef}
style={style} 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 <button
type="button" type="button"
{...attributes} {...attributes}
{...listeners} {...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" aria-label="Drag to reorder"
> >
<GripVertical className="h-4 w-4" /> <GripVertical className="h-4 w-4" />
@ -402,13 +404,13 @@ function SortableStageRow({
<Input <Input
value={stage.name} value={stage.name}
onChange={(e) => onNameChange(e.target.value)} 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 <Button
variant="ghost" variant="ghost"
size="icon-xs" size="icon-xs"
onClick={onRemove} onClick={onRemove}
className="text-slate-400 hover:text-red-400" className="text-muted-foreground hover:text-red-400"
> >
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
</Button> </Button>
@ -431,14 +433,14 @@ function ColorSwatch({
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} 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 }} style={{ backgroundColor: value }}
aria-label="Change color" aria-label="Change color"
/> />
{open && ( {open && (
<> <>
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} /> <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) => ( {colors.map((c) => (
<button <button
key={c} key={c}
@ -450,7 +452,8 @@ function ColorSwatch({
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110" className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
style={{ style={{
backgroundColor: c, 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"; "use client";
import { Check } from "lucide-react"; import { Check, Moon, Palette, SunMoon, Sun } from "lucide-react";
import { useTheme } from "@/hooks/use-theme"; 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 { 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: * Two independent controls: a mode toggle (light / dark) and the
* the whole change is a single CSS-variable swap on <html>, there's * accent grid. Either applies + persists immediately. No save button:
* nothing to roll back. The active card carries a check chip + a * each change is a single attribute swap on <html>, there's nothing
* primary-tinted border so the current pick is obvious. * to roll back.
* *
* Persistence: localStorage only (device-scoped). The boot script in * 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. * loads.
*/ */
export function AppearancePanel() { export function AppearancePanel() {
const { theme, setTheme } = useTheme(); const { theme, setTheme, mode, setMode } = useTheme();
return ( return (
<section className="space-y-4"> <section className="max-w-3xl animate-in fade-in-50 duration-200">
<div> <SettingsPanelHead
<h2 className="text-lg font-semibold text-white">Color theme</h2> title="Appearance"
<p className="mt-1 text-sm text-slate-400"> description="Set the mode and accent colour used across the app. Saved to this device — try it, it changes live."
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. <div className="space-y-4">
</p> <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>
<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"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{THEMES.map((t) => ( {THEMES.map((t) => (
@ -44,10 +69,55 @@ export function AppearancePanel() {
/> />
))} ))}
</div> </div>
</div>
</section> </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({ function ThemeCard({
id, id,
name, name,
@ -73,7 +143,7 @@ function ThemeCard({
"flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors", "flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
isActive isActive
? "border-primary/60 ring-2 ring-primary/40" ? "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"> <div className="flex items-center justify-between">
@ -93,8 +163,8 @@ function ThemeCard({
)} )}
</div> </div>
<div> <div>
<div className="text-sm font-semibold text-white">{name}</div> <div className="text-sm font-semibold text-foreground">{name}</div>
<div className="mt-1 text-xs leading-relaxed text-slate-400"> <div className="mt-1 text-xs leading-relaxed text-muted-foreground">
{tagline} {tagline}
</div> </div>
</div> </div>
@ -103,9 +173,9 @@ function ThemeCard({
aria-hidden aria-hidden
> >
<span className="flex-1" style={{ background: swatch }} /> <span className="flex-1" style={{ background: swatch }} />
<span className="w-3 bg-slate-700" /> <span className="w-3 bg-muted-foreground/60" />
<span className="w-3 bg-slate-800" /> <span className="w-3 bg-muted" />
<span className="w-3 bg-slate-900" /> <span className="w-3 bg-card" />
</div> </div>
<span className="sr-only">Theme id: {id}</span> <span className="sr-only">Theme id: {id}</span>
</button> </button>

View File

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

View File

@ -16,6 +16,7 @@ import {
CardTitle, CardTitle,
CardDescription, CardDescription,
} from "@/components/ui/card"; } from "@/components/ui/card";
import { SettingsPanelHead } from "./settings-panel-head";
/** /**
* Deals settings — account-wide default currency. * Deals settings — account-wide default currency.
@ -67,14 +68,18 @@ export function DealsSettings() {
} }
return ( return (
<section className="mt-4 space-y-4"> <section className="max-w-2xl animate-in fade-in-50 duration-200">
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent"> <SettingsPanelHead
title="Deals & currency"
description="The currency used for new deals and for pipeline and dashboard totals."
/>
<Card>
<CardHeader> <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" /> <Coins className="size-4 text-primary" />
Default currency Default currency
</CardTitle> </CardTitle>
<CardDescription className="text-slate-400"> <CardDescription className="text-muted-foreground">
New deals default to this currency, and pipeline and New deals default to this currency, and pipeline and
dashboard totals are shown in it. Existing deals keep the dashboard totals are shown in it. Existing deals keep the
currency they were saved with. currency they were saved with.
@ -82,12 +87,12 @@ export function DealsSettings() {
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid gap-2 sm:max-w-xs"> <div className="grid gap-2 sm:max-w-xs">
<Label className="text-slate-300">Currency</Label> <Label className="text-muted-foreground">Currency</Label>
<select <select
value={selected} value={selected}
onChange={(e) => setSelected(e.target.value)} onChange={(e) => setSelected(e.target.value)}
disabled={!canEditSettings || profileLoading} 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) => ( {CURRENCIES.map((c) => (
<option key={c.code} value={c.code}> <option key={c.code} value={c.code}>
@ -96,7 +101,7 @@ export function DealsSettings() {
))} ))}
</select> </select>
{!canEditSettings && ( {!canEditSettings && (
<p className="text-xs text-slate-500"> <p className="text-xs text-muted-foreground">
Only account admins can change the default currency. Only account admins can change the default currency.
</p> </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); 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 ? ( {result ? (
<> <>
<DialogHeader> <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" /> <Sparkles className="size-4 text-primary" />
Invite created Invite created
</DialogTitle> </DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-muted-foreground">
Share this link with your new teammate. They&apos;ll be able Share this link with your new teammate. They&apos;ll be able
to sign up (or sign in) and join the account as{' '} 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{' '} . 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'} {result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'}
</span> </span>
. .
@ -206,12 +206,12 @@ export function InviteMemberDialog({
</DialogHeader> </DialogHeader>
<div className="space-y-3 py-2"> <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"> <div className="flex gap-2">
<Input <Input
readOnly readOnly
value={result.url} 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()} onFocus={(e) => e.currentTarget.select()}
/> />
<Button <Button
@ -250,7 +250,7 @@ export function InviteMemberDialog({
className={buttonVariants({ className={buttonVariants({
variant: 'outline', variant: 'outline',
className: 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" /> <MessageCircle className="size-4" />
@ -258,7 +258,7 @@ export function InviteMemberDialog({
</a> </a>
</div> </div>
<DialogFooter className="bg-slate-900 border-slate-700"> <DialogFooter className="bg-popover border-border">
<Button <Button
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="bg-primary hover:bg-primary/90 text-primary-foreground" className="bg-primary hover:bg-primary/90 text-primary-foreground"
@ -270,8 +270,8 @@ export function InviteMemberDialog({
) : ( ) : (
<> <>
<DialogHeader> <DialogHeader>
<DialogTitle className="text-white">Invite a teammate</DialogTitle> <DialogTitle className="text-popover-foreground">Invite a teammate</DialogTitle>
<DialogDescription className="text-slate-400"> <DialogDescription className="text-muted-foreground">
Generate a one-time invite link. Share it via WhatsApp, Generate a one-time invite link. Share it via WhatsApp,
Slack, or any channel you like — no email service required. Slack, or any channel you like — no email service required.
</DialogDescription> </DialogDescription>
@ -279,12 +279,12 @@ export function InviteMemberDialog({
<div className="space-y-4 py-2"> <div className="space-y-4 py-2">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-slate-300">Role</Label> <Label className="text-muted-foreground">Role</Label>
<Select <Select
value={role} value={role}
onValueChange={(v) => v && setRole(v as InviteRole)} 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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -293,18 +293,18 @@ export function InviteMemberDialog({
<SelectItem value="viewer">Viewer</SelectItem> <SelectItem value="viewer">Viewer</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-slate-500"> <p className="text-xs text-muted-foreground">
{ROLE_DESCRIPTIONS[role]} {ROLE_DESCRIPTIONS[role]}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-slate-300">Link valid for</Label> <Label className="text-muted-foreground">Link valid for</Label>
<Select <Select
value={expiry} value={expiry}
onValueChange={(v) => v && setExpiry(v)} 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 /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -318,29 +318,29 @@ export function InviteMemberDialog({
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-slate-300"> <Label className="text-muted-foreground">
Label{' '} Label{' '}
<span className="text-xs text-slate-500">(optional)</span> <span className="text-xs text-muted-foreground">(optional)</span>
</Label> </Label>
<Input <Input
placeholder="e.g. Sara — support team" placeholder="e.g. Sara — support team"
value={label} value={label}
onChange={(e) => setLabel(e.target.value)} onChange={(e) => setLabel(e.target.value)}
maxLength={MAX_LABEL_LEN} 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 Helps you remember who you sent the link to in the pending
list below. list below.
</p> </p>
</div> </div>
</div> </div>
<DialogFooter className="bg-slate-900 border-slate-700"> <DialogFooter className="bg-popover border-border">
<Button <Button
variant="outline" variant="outline"
onClick={() => onOpenChange(false)} onClick={() => onOpenChange(false)}
className="border-slate-700 text-slate-300 hover:bg-slate-800" className="border-border text-muted-foreground hover:bg-muted"
> >
Cancel Cancel
</Button> </Button>

View File

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

View File

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

View File

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

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 ( return (
<> <>
<Card className="bg-slate-900/40 border-slate-800"> <Card>
<CardHeader> <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" /> <LogOut className="size-4 text-primary" />
Active sessions Active sessions
</CardTitle> </CardTitle>
<CardDescription className="text-slate-400"> <CardDescription className="text-muted-foreground">
Sign out of every device where you&apos;re logged in — including Sign out of every device where you&apos;re logged in — including
this one. Useful if you lost a laptop or shared your password. this one. Useful if you lost a laptop or shared your password.
</CardDescription> </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