mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge branch 'ArnasDon:main' into main
This commit is contained in:
commit
cf842fcee2
@ -7,8 +7,14 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
|
||||
|
||||
# Supabase service-role key. Bypasses RLS; used only by server-side
|
||||
# routes (the webhook and automation engine). Keep this secret —
|
||||
# never paste it into client code.
|
||||
# routes (the webhook, automation engine, and the public API key
|
||||
# auth path — see docs/public-api.md). Keep this secret — never
|
||||
# paste it into client code.
|
||||
#
|
||||
# Note: the public API (/api/v1) needs no new env var. API keys are
|
||||
# created in the dashboard (Settings → API keys) and stored hashed
|
||||
# in the database; this service-role key is what lets the auth path
|
||||
# look a presented key up without a user session.
|
||||
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
|
||||
|
||||
# WhatsApp token encryption (64 hex chars = 32 bytes, AES-256-GCM).
|
||||
@ -72,10 +78,12 @@ NEXT_PUBLIC_SITE_URL=https://crm.example.com
|
||||
# See docs/automations-and-cron.md.
|
||||
# AUTOMATION_CRON_SECRET=generate-a-long-random-string
|
||||
|
||||
# Meta App ID (Meta for Developers → App Settings → Basic). Required
|
||||
# only for the (planned) Resumable Upload flow that lets users attach
|
||||
# local media files as template header samples. Submitting templates
|
||||
# with a public sample URL works without it. Pair with META_APP_SECRET.
|
||||
# Meta App ID (Meta for Developers → App Settings → Basic). Required to
|
||||
# create/edit message templates with an IMAGE header: Meta only accepts a
|
||||
# Resumable-Upload media handle (not a plain URL) as the header sample, and
|
||||
# that upload is app-scoped. Without it, image-header template submission
|
||||
# returns a clear error; text/body-only templates are unaffected. Pair
|
||||
# with META_APP_SECRET.
|
||||
# META_APP_ID=your-meta-app-id
|
||||
|
||||
# When "true", POST /api/whatsapp/templates/submit skips the Meta call
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
ENCRYPTION_KEY: '0000000000000000000000000000000000000000000000000000000000000000'
|
||||
META_APP_SECRET: 'ci-dummy-meta-secret'
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
|
||||
@ -7,3 +7,4 @@ next-env.d.ts
|
||||
package-lock.json
|
||||
# Generated / vendored
|
||||
supabase/migrations
|
||||
public/opus
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@ -23,6 +23,20 @@ as the sole owner of their own account and sees identical data, and a
|
||||
solo owner who never invites anyone sees the same single-user app they
|
||||
always did.
|
||||
|
||||
### Added
|
||||
|
||||
- **Public REST API (`/api/v1`) — groundwork.** A scoped, revocable
|
||||
**API key** system so you can drive wacrm from your own scripts and
|
||||
automations. Create keys under **Settings → API keys** (admin+),
|
||||
grant only the scopes each integration needs, and authenticate with
|
||||
`Authorization: Bearer <key>`. Keys are account-scoped and stored
|
||||
hashed (plaintext shown once). This release ships the auth layer,
|
||||
scopes, per-key rate limiting, the management UI, and a
|
||||
`GET /api/v1/me` probe to verify a key; the data endpoints
|
||||
(`messages`, `contacts`, …) follow one at a time. See
|
||||
`docs/public-api.md`. **Migration required:** apply
|
||||
`supabase/migrations/026_api_keys.sql`. ([#245](https://github.com/ArnasDon/wacrm/issues/245))
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tenancy moves from per-user to per-account.** RLS on every
|
||||
|
||||
@ -40,6 +40,9 @@ clone or fork it to run your own CRM.
|
||||
is account-scoped, so one shared inbox can be staffed by a whole
|
||||
team. Solo use stays single-user with zero setup.
|
||||
- **Account management** — email, password, avatar, global sign-out.
|
||||
- **Public REST API** (`/api/v1`) with scoped, revocable API keys —
|
||||
build your own automations on top of your CRM. See
|
||||
[docs/public-api.md](./docs/public-api.md).
|
||||
|
||||
## Why fork this?
|
||||
|
||||
|
||||
130
docs/public-api.md
Normal file
130
docs/public-api.md
Normal 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)
|
||||
@ -12,6 +12,8 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// Vendored minified opus-recorder encoder worker (served statically).
|
||||
"public/opus/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
||||
@ -25,8 +25,12 @@ const SECURITY_HEADERS = [
|
||||
{ key: "X-Frame-Options", value: "DENY" },
|
||||
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||
{
|
||||
// Microphone is allowed for same-origin (`self`) so the inbox
|
||||
// composer can record voice notes via MediaRecorder. Everything
|
||||
// else stays denied — a compromised dependency can't silently grab
|
||||
// the camera / geolocation / etc.
|
||||
key: "Permissions-Policy",
|
||||
value: "camera=(), microphone=(), geolocation=(), payment=(), usb=()",
|
||||
value: "camera=(), microphone=(self), geolocation=(), payment=(), usb=()",
|
||||
},
|
||||
{
|
||||
key: "Content-Security-Policy-Report-Only",
|
||||
@ -42,6 +46,9 @@ const SECURITY_HEADERS = [
|
||||
// https URLs paste-able from the UI), OG images, data URLs for
|
||||
// tiny inline assets.
|
||||
"img-src 'self' data: blob: https:",
|
||||
// Outbound media previews (blob: from MediaRecorder + file picker)
|
||||
// and Supabase public-bucket audio/video the inbox renders.
|
||||
"media-src 'self' blob: https://*.supabase.co",
|
||||
"font-src 'self' data:",
|
||||
// Supabase REST + realtime (WSS). All Meta API calls happen
|
||||
// server-side, so graph.facebook.com does not belong here.
|
||||
|
||||
1040
package-lock.json
generated
1040
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@ -50,12 +50,13 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"lucide-react": "^1.17.0",
|
||||
"lucide-react": "^1.18.0",
|
||||
"next": "16.2.6",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"recharts": "^3.8.1",
|
||||
"shadcn": "^4.10.0",
|
||||
"shadcn": "^4.11.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
@ -67,16 +68,18 @@
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier": "^3.8.4",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^6",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"overrides": {
|
||||
"postcss": "^8.5.10",
|
||||
"ip-address": "^10.1.1",
|
||||
"fast-uri": "^3.1.2",
|
||||
"hono": "^4.12.21"
|
||||
"hono": "^4.12.25",
|
||||
"js-yaml": "^4.2.0",
|
||||
"@babel/core": "^7.29.6"
|
||||
}
|
||||
}
|
||||
|
||||
1
public/opus/encoderWorker.min.js
vendored
Normal file
1
public/opus/encoderWorker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -43,18 +43,18 @@ export default function ForgotPasswordPage() {
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<Card className="w-full max-w-md border-border bg-card">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<CheckCircle className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">
|
||||
<CardTitle className="text-xl text-foreground">
|
||||
Check your email
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
We've sent a password reset link to{" "}
|
||||
<span className="text-white">{email}</span>. Please check your
|
||||
<span className="text-foreground">{email}</span>. Please check your
|
||||
inbox.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@ -62,7 +62,7 @@ export default function ForgotPasswordPage() {
|
||||
<Link href="/login">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
Back to sign in
|
||||
</Button>
|
||||
@ -74,14 +74,14 @@ export default function ForgotPasswordPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<Card className="w-full max-w-md border-border bg-card">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<MessageSquare className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">Reset password</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
<CardTitle className="text-xl text-foreground">Reset password</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
Enter your email and we'll send you a reset link
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@ -94,7 +94,7 @@ export default function ForgotPasswordPage() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email" className="text-slate-300">
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
@ -104,7 +104,7 @@ export default function ForgotPasswordPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -119,7 +119,7 @@ export default function ForgotPasswordPage() {
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-6 flex items-center justify-center gap-2 text-sm text-slate-400 hover:text-slate-300"
|
||||
className="mt-6 flex items-center justify-center gap-2 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to sign in
|
||||
|
||||
@ -67,8 +67,8 @@ function LoginPageInner() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<Card className="w-full max-w-md border-border bg-card">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
{inviteToken ? (
|
||||
@ -77,10 +77,10 @@ function LoginPageInner() {
|
||||
<MessageSquare className="h-6 w-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">
|
||||
<CardTitle className="text-xl text-foreground">
|
||||
{inviteToken ? "Sign in to accept" : "Welcome back"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{inviteToken
|
||||
? "Sign in and we'll take you to the invitation."
|
||||
: "Sign in to your account"}
|
||||
@ -95,7 +95,7 @@ function LoginPageInner() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email" className="text-slate-300">
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
@ -105,13 +105,13 @@ function LoginPageInner() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password" className="text-slate-300">
|
||||
<Label htmlFor="password" className="text-muted-foreground">
|
||||
Password
|
||||
</Label>
|
||||
<Link
|
||||
@ -128,7 +128,7 @@ function LoginPageInner() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -141,7 +141,7 @@ function LoginPageInner() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-slate-400">
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href={
|
||||
|
||||
@ -91,18 +91,18 @@ function SignupPageInner() {
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<Card className="w-full max-w-md border-border bg-card">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<CheckCircle className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">
|
||||
<CardTitle className="text-xl text-foreground">
|
||||
Check your email
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
We've sent a confirmation link to{" "}
|
||||
<span className="text-white">{email}</span>. Please check your
|
||||
<span className="text-foreground">{email}</span>. Please check your
|
||||
inbox and click the link to verify your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@ -116,7 +116,7 @@ function SignupPageInner() {
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
Back to sign in
|
||||
</Button>
|
||||
@ -128,8 +128,8 @@ function SignupPageInner() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<Card className="w-full max-w-md border-border bg-card">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
{inviteToken ? (
|
||||
@ -138,10 +138,10 @@ function SignupPageInner() {
|
||||
<MessageSquare className="h-6 w-6 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">
|
||||
<CardTitle className="text-xl text-foreground">
|
||||
{inviteToken ? "Create account & join" : "Create account"}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{inviteToken
|
||||
? "Verify your email, then accept the invitation to join your team."
|
||||
: "Get started with CRM Template for WhatsApp"}
|
||||
@ -156,7 +156,7 @@ function SignupPageInner() {
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="fullName" className="text-slate-300">
|
||||
<Label htmlFor="fullName" className="text-muted-foreground">
|
||||
Full name
|
||||
</Label>
|
||||
<Input
|
||||
@ -166,12 +166,12 @@ function SignupPageInner() {
|
||||
value={fullName}
|
||||
onChange={(e) => setFullName(e.target.value)}
|
||||
required
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email" className="text-slate-300">
|
||||
<Label htmlFor="email" className="text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
@ -181,12 +181,12 @@ function SignupPageInner() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password" className="text-slate-300">
|
||||
<Label htmlFor="password" className="text-muted-foreground">
|
||||
Password
|
||||
</Label>
|
||||
<Input
|
||||
@ -196,12 +196,12 @@ function SignupPageInner() {
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirmPassword" className="text-slate-300">
|
||||
<Label htmlFor="confirmPassword" className="text-muted-foreground">
|
||||
Confirm password
|
||||
</Label>
|
||||
<Input
|
||||
@ -211,7 +211,7 @@ function SignupPageInner() {
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground focus-visible:border-primary focus-visible:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -224,7 +224,7 @@ function SignupPageInner() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-slate-400">
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href={
|
||||
|
||||
@ -87,21 +87,21 @@ export default function AutomationLogsPage({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/automations")}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Back"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">{automation.name}</h1>
|
||||
<p className="mt-0.5 text-sm text-slate-400">Execution logs</p>
|
||||
<h1 className="text-2xl font-bold text-foreground">{automation.name}</h1>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">Execution logs</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{logs.length === 0 ? (
|
||||
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-slate-800 bg-slate-900/40">
|
||||
<p className="text-sm text-white">No executions yet</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40">
|
||||
<p className="text-sm text-foreground">No executions yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Trigger this automation to see runs here.
|
||||
</p>
|
||||
</div>
|
||||
@ -112,7 +112,7 @@ export default function AutomationLogsPage({
|
||||
return (
|
||||
<li
|
||||
key={log.id}
|
||||
className="rounded-xl border border-slate-800 bg-slate-900"
|
||||
className="rounded-xl border border-border bg-card"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@ -120,26 +120,26 @@ export default function AutomationLogsPage({
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-slate-500" />
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-slate-500" />
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
<StatusBadge status={log.status} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-white">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{log.contact?.name ?? log.contact?.phone ?? "Unknown contact"}
|
||||
</div>
|
||||
<div className="truncate text-xs text-slate-500">
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{log.trigger_event} · {log.steps_executed?.length ?? 0} step
|
||||
{log.steps_executed?.length === 1 ? "" : "s"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{formatRelative(log.created_at)}
|
||||
</div>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="border-t border-slate-800 px-4 py-3">
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{log.error_message && (
|
||||
<p className="mb-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
||||
{log.error_message}
|
||||
@ -150,7 +150,7 @@ export default function AutomationLogsPage({
|
||||
<StepRow key={i} result={r} />
|
||||
))}
|
||||
{(log.steps_executed ?? []).length === 0 && (
|
||||
<li className="text-xs text-slate-500">No steps recorded.</li>
|
||||
<li className="text-xs text-muted-foreground">No steps recorded.</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
@ -196,9 +196,9 @@ function StepRow({ result }: { result: AutomationLogStepResult }) {
|
||||
>
|
||||
{ok ? <Check className="h-3 w-3" /> : <X className="h-3 w-3" />}
|
||||
</span>
|
||||
<span className="text-slate-300">{result.step_type}</span>
|
||||
<span className="text-muted-foreground">{result.step_type}</span>
|
||||
{result.detail && (
|
||||
<span className="truncate text-slate-500">— {result.detail}</span>
|
||||
<span className="truncate text-muted-foreground">— {result.detail}</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
|
||||
@ -160,8 +160,8 @@ export default function AutomationsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Automations</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<h1 className="text-2xl font-bold text-foreground">Automations</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Build workflows that react to WhatsApp® events automatically.
|
||||
</p>
|
||||
</div>
|
||||
@ -178,7 +178,7 @@ export default function AutomationsPage() {
|
||||
|
||||
{showTemplates && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-sm font-semibold text-slate-300">Quick-start templates</h2>
|
||||
<h2 className="mb-3 text-sm font-semibold text-muted-foreground">Quick-start templates</h2>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
{TEMPLATE_ORDER.map((slug) => {
|
||||
const t = AUTOMATION_TEMPLATES[slug]
|
||||
@ -187,13 +187,13 @@ export default function AutomationsPage() {
|
||||
<button
|
||||
key={slug}
|
||||
onClick={() => startFromTemplate(slug)}
|
||||
className="group flex flex-col items-start rounded-xl border border-slate-800 bg-slate-900 p-4 text-left transition-colors hover:border-primary/50 hover:bg-slate-900/80"
|
||||
className="group flex flex-col items-start rounded-xl border border-border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-card/80"
|
||||
>
|
||||
<div className="mb-3 flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary group-hover:bg-primary/15">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-white">{t.name}</div>
|
||||
<p className="mt-1 text-xs text-slate-400">{t.description}</p>
|
||||
<div className="text-sm font-semibold text-foreground">{t.name}</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t.description}</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@ -202,12 +202,12 @@ export default function AutomationsPage() {
|
||||
)}
|
||||
|
||||
{automations.length === 0 ? (
|
||||
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-slate-800 bg-slate-900/40">
|
||||
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-dashed border-border bg-card/40">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<Zap className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<p className="mt-3 text-sm font-medium text-white">No automations yet</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<p className="mt-3 text-sm font-medium text-foreground">No automations yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Pick a template above or create one from scratch.
|
||||
</p>
|
||||
</div>
|
||||
@ -233,7 +233,7 @@ export default function AutomationsPage() {
|
||||
<DialogTitle>Delete automation</DialogTitle>
|
||||
<DialogDescription>
|
||||
This permanently removes{" "}
|
||||
<span className="text-white">{pendingDelete?.name}</span> and its execution
|
||||
<span className="text-foreground">{pendingDelete?.name}</span> and its execution
|
||||
history. This cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@ -277,7 +277,7 @@ function AutomationCard({
|
||||
}) {
|
||||
const meta = triggerMeta(automation.trigger_type)
|
||||
return (
|
||||
<li className="rounded-xl border border-slate-800 bg-slate-900 transition-colors hover:border-slate-700">
|
||||
<li className="rounded-xl border border-border bg-card transition-colors hover:border-border">
|
||||
<div className="flex items-center gap-4 p-4">
|
||||
<div
|
||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-primary/10"
|
||||
@ -292,7 +292,7 @@ function AutomationCard({
|
||||
className="min-w-0 flex-1 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold text-white">
|
||||
<span className="truncate text-sm font-semibold text-foreground">
|
||||
{automation.name}
|
||||
</span>
|
||||
{automation.is_active && (
|
||||
@ -303,9 +303,9 @@ function AutomationCard({
|
||||
)}
|
||||
</div>
|
||||
{automation.description && (
|
||||
<p className="mt-0.5 truncate text-xs text-slate-400">{automation.description}</p>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">{automation.description}</p>
|
||||
)}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium",
|
||||
@ -332,7 +332,7 @@ function AutomationCard({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
aria-label="Open menu"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white data-[popup-open]:bg-slate-800"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground data-[popup-open]:bg-muted"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@ -50,15 +50,15 @@ interface StatCardProps {
|
||||
function StatCard({ label, value, total, icon, color }: StatCardProps) {
|
||||
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${color}`}>
|
||||
{icon}
|
||||
</div>
|
||||
<span className="text-xs text-slate-500">{pct}%</span>
|
||||
<span className="text-xs text-muted-foreground">{pct}%</span>
|
||||
</div>
|
||||
<p className="mt-3 text-2xl font-bold text-white">{value.toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-400">{label}</p>
|
||||
<p className="mt-3 text-2xl font-bold text-foreground">{value.toLocaleString()}</p>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -77,8 +77,8 @@ interface FunnelStep {
|
||||
function FunnelChart({ steps }: { steps: FunnelStep[] }) {
|
||||
const max = Math.max(...steps.map((s) => s.value), 1);
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||
<h3 className="mb-4 text-sm font-medium text-white">Funnel</h3>
|
||||
<div className="rounded-xl border border-border bg-card p-4">
|
||||
<h3 className="mb-4 text-sm font-medium text-foreground">Funnel</h3>
|
||||
<div className="space-y-2">
|
||||
{steps.map((step) => {
|
||||
const pctOfMax = Math.max(5, Math.round((step.value / max) * 100));
|
||||
@ -88,17 +88,17 @@ function FunnelChart({ steps }: { steps: FunnelStep[] }) {
|
||||
: 0;
|
||||
return (
|
||||
<div key={step.label} className="flex items-center gap-3">
|
||||
<span className="w-20 shrink-0 text-xs text-slate-400">
|
||||
<span className="w-20 shrink-0 text-xs text-muted-foreground">
|
||||
{step.label}
|
||||
</span>
|
||||
<div className="relative h-7 flex-1 rounded-full bg-slate-800">
|
||||
<div className="relative h-7 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-7 rounded-full ${step.color} transition-[width] duration-500`}
|
||||
style={{ width: `${pctOfMax}%` }}
|
||||
/>
|
||||
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-white">
|
||||
<span className="absolute inset-0 flex items-center px-3 text-xs font-medium text-foreground">
|
||||
{step.value.toLocaleString()}
|
||||
<span className="ml-2 text-slate-300/80">
|
||||
<span className="ml-2 text-muted-foreground/80">
|
||||
({pctOfSent}%)
|
||||
</span>
|
||||
</span>
|
||||
@ -280,20 +280,20 @@ export default function BroadcastDetailPage() {
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => router.push('/broadcasts')}
|
||||
className="border-slate-700"
|
||||
className="border-border"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold text-white">{broadcast.name}</h1>
|
||||
<h1 className="text-2xl font-bold text-foreground">{broadcast.name}</h1>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-slate-400">
|
||||
<div className="mt-1 flex items-center gap-3 text-sm text-muted-foreground">
|
||||
<span>Template: {broadcast.template_name}</span>
|
||||
<span>-</span>
|
||||
<span>
|
||||
@ -315,7 +315,7 @@ export default function BroadcastDetailPage() {
|
||||
size="sm"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
disabled={deleting}
|
||||
className="h-7 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||||
className="h-7 border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@ -354,7 +354,7 @@ export default function BroadcastDetailPage() {
|
||||
value={broadcast.total_recipients}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
color="bg-slate-800 text-slate-300"
|
||||
color="bg-muted text-muted-foreground"
|
||||
/>
|
||||
<StatCard
|
||||
label="Sent"
|
||||
@ -396,9 +396,9 @@ export default function BroadcastDetailPage() {
|
||||
<FunnelChart steps={funnelSteps} />
|
||||
|
||||
{/* Recipients Table */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-800 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-white">
|
||||
<div className="rounded-xl border border-border bg-card">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-foreground">
|
||||
Recipients ({filteredRecipients.length}
|
||||
{statusFilter !== 'all' ? ` of ${recipients.length}` : ''})
|
||||
</h2>
|
||||
@ -409,7 +409,7 @@ export default function BroadcastDetailPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
/>
|
||||
}
|
||||
>
|
||||
@ -419,11 +419,11 @@ export default function BroadcastDetailPage() {
|
||||
: getRecipientStatus(statusFilter).label}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="border-slate-700 bg-slate-900">
|
||||
<DropdownMenuContent className="border-border bg-popover">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={
|
||||
statusFilter === 'all' ? 'text-primary' : 'text-slate-300'
|
||||
statusFilter === 'all' ? 'text-primary' : 'text-popover-foreground'
|
||||
}
|
||||
>
|
||||
All statuses
|
||||
@ -435,7 +435,7 @@ export default function BroadcastDetailPage() {
|
||||
className={
|
||||
statusFilter === s
|
||||
? 'text-primary'
|
||||
: 'text-slate-300'
|
||||
: 'text-popover-foreground'
|
||||
}
|
||||
>
|
||||
{getRecipientStatus(s).label}
|
||||
@ -449,7 +449,7 @@ export default function BroadcastDetailPage() {
|
||||
size="sm"
|
||||
onClick={handleExport}
|
||||
disabled={recipients.length === 0}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Export CSV
|
||||
@ -459,7 +459,7 @@ export default function BroadcastDetailPage() {
|
||||
|
||||
{filteredRecipients.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-400">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{recipients.length === 0
|
||||
? 'No recipients found.'
|
||||
: 'No recipients match this filter.'}
|
||||
@ -469,25 +469,25 @@ export default function BroadcastDetailPage() {
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Contact</TableHead>
|
||||
<TableHead className="text-slate-400">Phone</TableHead>
|
||||
<TableHead className="text-slate-400">Status</TableHead>
|
||||
<TableHead className="text-slate-400">Sent</TableHead>
|
||||
<TableHead className="text-slate-400">Delivered</TableHead>
|
||||
<TableHead className="text-slate-400">Read</TableHead>
|
||||
<TableHead className="text-slate-400">Error</TableHead>
|
||||
<TableRow className="border-border hover:bg-transparent">
|
||||
<TableHead className="text-muted-foreground">Contact</TableHead>
|
||||
<TableHead className="text-muted-foreground">Phone</TableHead>
|
||||
<TableHead className="text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="text-muted-foreground">Sent</TableHead>
|
||||
<TableHead className="text-muted-foreground">Delivered</TableHead>
|
||||
<TableHead className="text-muted-foreground">Read</TableHead>
|
||||
<TableHead className="text-muted-foreground">Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRecipients.map((recipient) => {
|
||||
const rStatus = getRecipientStatus(recipient.status);
|
||||
return (
|
||||
<TableRow key={recipient.id} className="border-slate-800">
|
||||
<TableCell className="font-medium text-white">
|
||||
<TableRow key={recipient.id} className="border-border">
|
||||
<TableCell className="font-medium text-foreground">
|
||||
{recipient.contact?.name ?? 'Unknown'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300">
|
||||
<TableCell className="text-muted-foreground">
|
||||
{recipient.contact?.phone ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@ -497,17 +497,17 @@ export default function BroadcastDetailPage() {
|
||||
{rStatus.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
<TableCell className="text-muted-foreground">
|
||||
{recipient.sent_at
|
||||
? new Date(recipient.sent_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
<TableCell className="text-muted-foreground">
|
||||
{recipient.delivered_at
|
||||
? new Date(recipient.delivered_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
<TableCell className="text-muted-foreground">
|
||||
{recipient.read_at
|
||||
? new Date(recipient.read_at).toLocaleString()
|
||||
: '-'}
|
||||
|
||||
@ -41,6 +41,7 @@ export default function NewBroadcastPage() {
|
||||
const [variables, setVariables] = useState<
|
||||
Record<string, { type: 'static' | 'field' | 'custom_field'; value: string }>
|
||||
>({});
|
||||
const [headerMediaUrl, setHeaderMediaUrl] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
|
||||
async function handleSend() {
|
||||
@ -58,6 +59,7 @@ export default function NewBroadcastPage() {
|
||||
excludeTagIds: audience.excludeTagIds,
|
||||
},
|
||||
variables,
|
||||
headerMediaUrl,
|
||||
});
|
||||
router.push(`/broadcasts/${broadcastId}`);
|
||||
} catch (err) {
|
||||
@ -129,8 +131,8 @@ export default function NewBroadcastPage() {
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">New Broadcast</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<h1 className="text-2xl font-bold text-foreground">New Broadcast</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Create and send a broadcast message to your contacts.
|
||||
</p>
|
||||
</div>
|
||||
@ -150,14 +152,14 @@ export default function NewBroadcastPage() {
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: isActive
|
||||
? 'border-2 border-primary bg-primary/10 text-primary'
|
||||
: 'border border-slate-700 bg-slate-800 text-slate-500'
|
||||
: 'border border-border bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`hidden text-sm font-medium sm:block ${
|
||||
isActive ? 'text-white' : isCompleted ? 'text-primary' : 'text-slate-500'
|
||||
isActive ? 'text-foreground' : isCompleted ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
@ -166,7 +168,7 @@ export default function NewBroadcastPage() {
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`mx-3 h-px flex-1 ${
|
||||
index < currentStep ? 'bg-primary' : 'bg-slate-800'
|
||||
index < currentStep ? 'bg-primary' : 'bg-muted'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
@ -205,6 +207,8 @@ export default function NewBroadcastPage() {
|
||||
template={template}
|
||||
variables={variables}
|
||||
onUpdate={setVariables}
|
||||
headerMediaUrl={headerMediaUrl}
|
||||
onHeaderMediaUrlChange={setHeaderMediaUrl}
|
||||
onNext={() => setCurrentStep(3)}
|
||||
onBack={() => setCurrentStep(1)}
|
||||
/>
|
||||
|
||||
@ -43,10 +43,10 @@ function RateCell({
|
||||
const pct = percent(value, total);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-10 text-right text-xs tabular-nums text-slate-300">
|
||||
<span className="w-10 text-right text-xs tabular-nums text-muted-foreground">
|
||||
{pct}%
|
||||
</span>
|
||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-slate-800">
|
||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-1.5 rounded-full ${color}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
@ -155,7 +155,7 @@ export default function BroadcastsPage() {
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="Broadcast in progress"
|
||||
className="broadcast-indeterminate fixed inset-x-0 top-0 z-40 h-0.5 overflow-hidden bg-slate-800"
|
||||
className="broadcast-indeterminate fixed inset-x-0 top-0 z-40 h-0.5 overflow-hidden bg-muted"
|
||||
>
|
||||
<div className="broadcast-indeterminate-bar h-0.5 bg-primary" />
|
||||
<style jsx>{`
|
||||
@ -179,8 +179,8 @@ export default function BroadcastsPage() {
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Broadcasts</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<h1 className="text-2xl font-bold text-foreground">Broadcasts</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Send bulk messages to your contacts using approved templates.
|
||||
</p>
|
||||
</div>
|
||||
@ -196,10 +196,10 @@ export default function BroadcastsPage() {
|
||||
</div>
|
||||
|
||||
{broadcasts.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900">
|
||||
<Radio className="mb-3 h-10 w-10 text-slate-600" />
|
||||
<p className="text-sm font-medium text-white">No broadcasts yet</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-border bg-card">
|
||||
<Radio className="mb-3 h-10 w-10 text-muted-foreground" />
|
||||
<p className="text-sm font-medium text-foreground">No broadcasts yet</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Create your first broadcast to reach your contacts at scale.
|
||||
</p>
|
||||
<GatedButton
|
||||
@ -213,19 +213,19 @@ export default function BroadcastsPage() {
|
||||
</GatedButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="overflow-x-auto rounded-xl border border-border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Name</TableHead>
|
||||
<TableHead className="hidden text-slate-400 md:table-cell">Template</TableHead>
|
||||
<TableHead className="hidden text-right text-slate-400 sm:table-cell">
|
||||
<TableRow className="border-border hover:bg-transparent">
|
||||
<TableHead className="text-muted-foreground">Name</TableHead>
|
||||
<TableHead className="hidden text-muted-foreground md:table-cell">Template</TableHead>
|
||||
<TableHead className="hidden text-right text-muted-foreground sm:table-cell">
|
||||
Recipients
|
||||
</TableHead>
|
||||
<TableHead className="hidden text-slate-400 lg:table-cell">Delivery</TableHead>
|
||||
<TableHead className="hidden text-slate-400 lg:table-cell">Read</TableHead>
|
||||
<TableHead className="text-slate-400">Status</TableHead>
|
||||
<TableHead className="hidden text-slate-400 sm:table-cell">Date</TableHead>
|
||||
<TableHead className="hidden text-muted-foreground lg:table-cell">Delivery</TableHead>
|
||||
<TableHead className="hidden text-muted-foreground lg:table-cell">Read</TableHead>
|
||||
<TableHead className="text-muted-foreground">Status</TableHead>
|
||||
<TableHead className="hidden text-muted-foreground sm:table-cell">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -234,16 +234,16 @@ export default function BroadcastsPage() {
|
||||
return (
|
||||
<TableRow
|
||||
key={broadcast.id}
|
||||
className="cursor-pointer border-slate-800 hover:bg-slate-800/50"
|
||||
className="cursor-pointer border-border hover:bg-muted/50"
|
||||
onClick={() => router.push(`/broadcasts/${broadcast.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium text-white">
|
||||
<TableCell className="font-medium text-foreground">
|
||||
{broadcast.name}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-slate-300 md:table-cell">
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">
|
||||
{broadcast.template_name}
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-right text-slate-300 tabular-nums sm:table-cell">
|
||||
<TableCell className="hidden text-right text-muted-foreground tabular-nums sm:table-cell">
|
||||
{broadcast.total_recipients}
|
||||
</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">
|
||||
@ -273,7 +273,7 @@ export default function BroadcastsPage() {
|
||||
{status.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-slate-400 sm:table-cell">
|
||||
<TableCell className="hidden text-muted-foreground sm:table-cell">
|
||||
{new Date(broadcast.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import type { Contact, Tag, ContactTag } from '@/types';
|
||||
@ -29,6 +29,11 @@ import {
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
@ -41,6 +46,8 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
SlidersHorizontal,
|
||||
Filter,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { ContactForm } from '@/components/contacts/contact-form';
|
||||
import { ContactDetailView } from '@/components/contacts/contact-detail-view';
|
||||
@ -48,6 +55,7 @@ import { ImportModal } from '@/components/contacts/import-modal';
|
||||
import { CustomFieldsManager } from '@/components/contacts/custom-fields-manager';
|
||||
import { useCan } from '@/hooks/use-can';
|
||||
import { GatedButton } from '@/components/ui/gated-button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
@ -65,6 +73,8 @@ export default function ContactsPage() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
// Tag filter — contacts shown must have ANY of these tags (OR).
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
|
||||
// Modals
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
@ -78,57 +88,107 @@ export default function ContactsPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// Bulk selection (page-scoped — only the loaded rows are selectable)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||
|
||||
// All tags for display
|
||||
const [tagsMap, setTagsMap] = useState<Record<string, Tag>>({});
|
||||
|
||||
// Guards against out-of-order fetch responses: each fetchContacts run
|
||||
// claims a sequence number and only the latest is allowed to commit its
|
||||
// results. Without this, rapidly toggling tag filters could let a slower
|
||||
// earlier request resolve last and render stale rows.
|
||||
const fetchSeq = useRef(0);
|
||||
|
||||
const fetchTags = useCallback(async () => {
|
||||
const { data } = await supabase.from('tags').select('*');
|
||||
if (data) {
|
||||
const map: Record<string, Tag> = {};
|
||||
data.forEach((t) => (map[t.id] = t));
|
||||
setTagsMap(map);
|
||||
// Drop any filter selections whose tag no longer exists (e.g. a tag
|
||||
// deleted elsewhere) so it can't linger invisibly in the query.
|
||||
setSelectedTagIds((prev) => {
|
||||
const pruned = prev.filter((id) => map[id]);
|
||||
return pruned.length === prev.length ? prev : pruned;
|
||||
});
|
||||
}
|
||||
}, [supabase]);
|
||||
|
||||
const fetchContacts = useCallback(async () => {
|
||||
const seq = ++fetchSeq.current;
|
||||
setLoading(true);
|
||||
// The visible rows are about to change — drop any selection that
|
||||
// referred to the old page/search results so the bulk bar can't
|
||||
// act on rows the user can no longer see.
|
||||
setSelected(new Set());
|
||||
|
||||
const from = page * PAGE_SIZE;
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
const term = search.trim();
|
||||
|
||||
let contactRows: Contact[];
|
||||
let count: number;
|
||||
|
||||
if (selectedTagIds.length > 0) {
|
||||
// Tag filter active — resolve it server-side (join + distinct +
|
||||
// windowed total count + pagination) so a tag covering many
|
||||
// contacts can't silently truncate the result or overflow an IN
|
||||
// clause. See migration 025_filter_contacts_by_tags.
|
||||
const { data, error } = await supabase.rpc('filter_contacts_by_tags', {
|
||||
p_tag_ids: selectedTagIds,
|
||||
p_search: term || null,
|
||||
p_limit: PAGE_SIZE,
|
||||
p_offset: from,
|
||||
});
|
||||
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
|
||||
if (error) {
|
||||
toast.error('Failed to load contacts');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const rows = (data ?? []) as { contact: Contact; total_count: number }[];
|
||||
contactRows = rows.map((r) => r.contact);
|
||||
count = rows.length > 0 ? Number(rows[0].total_count) : 0;
|
||||
} else {
|
||||
let query = supabase
|
||||
.from('contacts')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
.range(from, to);
|
||||
|
||||
if (search.trim()) {
|
||||
const term = `%${search.trim()}%`;
|
||||
query = query.or(`name.ilike.${term},phone.ilike.${term},email.ilike.${term}`);
|
||||
if (term) {
|
||||
const like = `%${term}%`;
|
||||
query = query.or(`name.ilike.${like},phone.ilike.${like},email.ilike.${like}`);
|
||||
}
|
||||
|
||||
const { data, count, error } = await query;
|
||||
|
||||
const { data, count: exactCount, error } = await query;
|
||||
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
|
||||
if (error) {
|
||||
toast.error('Failed to load contacts');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
contactRows = data ?? [];
|
||||
count = exactCount ?? 0;
|
||||
}
|
||||
|
||||
setTotalCount(count ?? 0);
|
||||
setTotalCount(count);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
if (contactRows.length === 0) {
|
||||
setContacts([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch tags for these contacts
|
||||
const contactIds = data.map((c) => c.id);
|
||||
const contactIds = contactRows.map((c) => c.id);
|
||||
const { data: contactTags } = await supabase
|
||||
.from('contact_tags')
|
||||
.select('contact_id, tag_id')
|
||||
.in('contact_id', contactIds);
|
||||
if (seq !== fetchSeq.current) return; // superseded by a newer fetch
|
||||
|
||||
const tagsByContact: Record<string, string[]> = {};
|
||||
contactTags?.forEach((ct) => {
|
||||
@ -136,7 +196,7 @@ export default function ContactsPage() {
|
||||
tagsByContact[ct.contact_id].push(ct.tag_id);
|
||||
});
|
||||
|
||||
const enriched: ContactWithTags[] = data.map((c) => ({
|
||||
const enriched: ContactWithTags[] = contactRows.map((c) => ({
|
||||
...c,
|
||||
tags: (tagsByContact[c.id] ?? [])
|
||||
.map((tid) => tagsMap[tid])
|
||||
@ -145,7 +205,7 @@ export default function ContactsPage() {
|
||||
|
||||
setContacts(enriched);
|
||||
setLoading(false);
|
||||
}, [supabase, page, search, tagsMap]);
|
||||
}, [supabase, page, search, selectedTagIds, tagsMap]);
|
||||
|
||||
// Load-once-on-mount-ish data fetches. Each setter inside runs
|
||||
// inside an async promise completion (Supabase await), not
|
||||
@ -208,17 +268,82 @@ export default function ContactsPage() {
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
|
||||
const allOnPageSelected =
|
||||
contacts.length > 0 && contacts.every((c) => selected.has(c.id));
|
||||
const someOnPageSelected = contacts.some((c) => selected.has(c.id));
|
||||
|
||||
function toggleSelectAll() {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (allOnPageSelected) {
|
||||
contacts.forEach((c) => next.delete(c.id));
|
||||
} else {
|
||||
contacts.forEach((c) => next.add(c.id));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSelect(id: string) {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleBulkDelete() {
|
||||
const ids = [...selected];
|
||||
if (ids.length === 0) return;
|
||||
setDeleting(true);
|
||||
|
||||
const { error } = await supabase.from('contacts').delete().in('id', ids);
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to delete contacts');
|
||||
} else {
|
||||
toast.success(`${ids.length} contact${ids.length === 1 ? '' : 's'} deleted`);
|
||||
setSelected(new Set());
|
||||
fetchContacts();
|
||||
}
|
||||
|
||||
setDeleting(false);
|
||||
setBulkDeleteOpen(false);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||
const hasNext = page < totalPages - 1;
|
||||
const hasPrev = page > 0;
|
||||
|
||||
// Tag filter helpers. Every change resets to page 0 — the result set
|
||||
// shrinks/grows so page N may no longer be valid (mirrors the search box).
|
||||
const allTags = Object.values(tagsMap).sort((a, b) =>
|
||||
a.name.localeCompare(b.name)
|
||||
);
|
||||
const hasActiveFilters = search.trim().length > 0 || selectedTagIds.length > 0;
|
||||
|
||||
function toggleTagFilter(tagId: string) {
|
||||
setSelectedTagIds((prev) =>
|
||||
prev.includes(tagId)
|
||||
? prev.filter((id) => id !== tagId)
|
||||
: [...prev, tagId]
|
||||
);
|
||||
setPage(0);
|
||||
}
|
||||
|
||||
function clearTagFilters() {
|
||||
setSelectedTagIds([]);
|
||||
setPage(0);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Contacts</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
<h1 className="text-2xl font-bold text-foreground">Contacts</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`}
|
||||
</p>
|
||||
</div>
|
||||
@ -227,7 +352,7 @@ export default function ContactsPage() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setCustomFieldsOpen(true)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Custom fields
|
||||
@ -238,7 +363,7 @@ export default function ContactsPage() {
|
||||
canAct={canEdit}
|
||||
gateReason="add or import contacts"
|
||||
onClick={() => setImportOpen(true)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
Import
|
||||
@ -255,9 +380,11 @@ export default function ContactsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-slate-500" />
|
||||
{/* Search + tag filter */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative w-full max-w-sm">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
@ -267,48 +394,187 @@ export default function ContactsPage() {
|
||||
setPage(0);
|
||||
}}
|
||||
placeholder="Search by name, phone, or email..."
|
||||
className="pl-8 bg-slate-900 border-slate-700 text-white placeholder:text-slate-500"
|
||||
className="pl-8 bg-card border-border text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-border text-muted-foreground hover:bg-muted shrink-0"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Filter className="size-4" />
|
||||
Filter by tags
|
||||
{selectedTagIds.length > 0 && (
|
||||
<span className="ml-1 inline-flex items-center justify-center rounded-full bg-primary px-1.5 text-[10px] font-semibold text-primary-foreground">
|
||||
{selectedTagIds.length}
|
||||
</span>
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-0">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border">
|
||||
<span className="text-sm font-medium text-popover-foreground">
|
||||
Filter by tags
|
||||
</span>
|
||||
{selectedTagIds.length > 0 && (
|
||||
<button
|
||||
onClick={clearTagFilters}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{allTags.length === 0 ? (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground text-center">
|
||||
No tags yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{allTags.map((tag) => (
|
||||
<label
|
||||
key={tag.id}
|
||||
className="flex items-center gap-2.5 px-3 py-1.5 cursor-pointer hover:bg-muted/50"
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedTagIds.includes(tag.id)}
|
||||
onCheckedChange={() => toggleTagFilter(tag.id)}
|
||||
aria-label={`Filter by ${tag.name}`}
|
||||
/>
|
||||
<span
|
||||
className="size-2.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span className="text-sm text-popover-foreground truncate">
|
||||
{tag.name}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Active tag-filter chips */}
|
||||
{selectedTagIds.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{selectedTagIds.map((id) => {
|
||||
const tag = tagsMap[id];
|
||||
if (!tag) return null;
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11px] font-medium"
|
||||
style={{
|
||||
backgroundColor: tag.color + '20',
|
||||
color: tag.color,
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
<button
|
||||
onClick={() => toggleTagFilter(id)}
|
||||
aria-label={`Remove ${tag.name} filter`}
|
||||
className="hover:opacity-70"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
onClick={clearTagFilters}
|
||||
className="text-xs text-muted-foreground hover:text-foreground px-1"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selected.size > 0 && (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg border border-border bg-muted/40 px-4 py-2">
|
||||
<p className="text-sm text-foreground">
|
||||
<span className="font-medium">{selected.size}</span>{' '}
|
||||
{selected.size === 1 ? 'contact' : 'contacts'} selected
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelected(new Set())}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
<GatedButton
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
canAct={canEdit}
|
||||
gateReason="delete contacts"
|
||||
onClick={() => setBulkDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete selected
|
||||
</GatedButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-slate-800 overflow-hidden">
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Name</TableHead>
|
||||
<TableHead className="text-slate-400">Phone</TableHead>
|
||||
<TableHead className="text-slate-400 hidden md:table-cell">Email</TableHead>
|
||||
<TableHead className="text-slate-400 hidden lg:table-cell">Company</TableHead>
|
||||
<TableHead className="text-slate-400 hidden md:table-cell">Tags</TableHead>
|
||||
<TableHead className="text-slate-400 hidden lg:table-cell">Created</TableHead>
|
||||
<TableHead className="text-slate-400 w-12" />
|
||||
<TableRow className="border-border hover:bg-transparent">
|
||||
<TableHead className="w-10">
|
||||
<Checkbox
|
||||
checked={allOnPageSelected}
|
||||
indeterminate={!allOnPageSelected && someOnPageSelected}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
disabled={contacts.length === 0}
|
||||
aria-label="Select all contacts on this page"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="text-muted-foreground">Name</TableHead>
|
||||
<TableHead className="text-muted-foreground">Phone</TableHead>
|
||||
<TableHead className="text-muted-foreground hidden md:table-cell">Email</TableHead>
|
||||
<TableHead className="text-muted-foreground hidden lg:table-cell">Company</TableHead>
|
||||
<TableHead className="text-muted-foreground hidden md:table-cell">Tags</TableHead>
|
||||
<TableHead className="text-muted-foreground hidden lg:table-cell">Created</TableHead>
|
||||
<TableHead className="text-muted-foreground w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<TableRow className="border-border">
|
||||
<TableCell colSpan={8} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
<p className="text-sm text-slate-500">Loading contacts...</p>
|
||||
<p className="text-sm text-muted-foreground">Loading contacts...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : contacts.length === 0 ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<TableRow className="border-border">
|
||||
<TableCell colSpan={8} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users className="size-8 text-slate-600" />
|
||||
<p className="text-sm text-slate-500">
|
||||
{search ? 'No contacts match your search.' : 'No contacts yet.'}
|
||||
<Users className="size-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasActiveFilters
|
||||
? 'No contacts match your filters.'
|
||||
: 'No contacts yet.'}
|
||||
</p>
|
||||
{!search && (
|
||||
{!hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openAddForm}
|
||||
className="mt-2 border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="mt-2 border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add your first contact
|
||||
@ -321,20 +587,27 @@ export default function ContactsPage() {
|
||||
contacts.map((contact) => (
|
||||
<TableRow
|
||||
key={contact.id}
|
||||
className="border-slate-800 hover:bg-slate-900/50 cursor-pointer"
|
||||
className="border-border hover:bg-muted/50 cursor-pointer"
|
||||
onClick={() => openDetail(contact.id)}
|
||||
>
|
||||
<TableCell className="text-white font-medium">
|
||||
{contact.name || <span className="text-slate-500 italic">Unnamed</span>}
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={selected.has(contact.id)}
|
||||
onCheckedChange={() => toggleSelect(contact.id)}
|
||||
aria-label={`Select ${contact.name || contact.phone}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300 font-mono text-xs">
|
||||
<TableCell className="text-foreground font-medium">
|
||||
{contact.name || <span className="text-muted-foreground italic">Unnamed</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground font-mono text-xs">
|
||||
{contact.phone}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400 hidden md:table-cell text-sm">
|
||||
{contact.email || <span className="text-slate-600">-</span>}
|
||||
<TableCell className="text-muted-foreground hidden md:table-cell text-sm">
|
||||
{contact.email || <span className="text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400 hidden lg:table-cell text-sm">
|
||||
{contact.company || <span className="text-slate-600">-</span>}
|
||||
<TableCell className="text-muted-foreground hidden lg:table-cell text-sm">
|
||||
{contact.company || <span className="text-muted-foreground">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@ -352,16 +625,16 @@ export default function ContactsPage() {
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-slate-600 text-xs">-</span>
|
||||
<span className="text-muted-foreground text-xs">-</span>
|
||||
)}
|
||||
{contact.tags && contact.tags.length > 3 && (
|
||||
<span className="text-[10px] text-slate-500">
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
+{contact.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-500 text-xs hidden lg:table-cell">
|
||||
<TableCell className="text-muted-foreground text-xs hidden lg:table-cell">
|
||||
{new Date(contact.created_at).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@ -375,7 +648,7 @@ export default function ContactsPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-slate-400 hover:text-white"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
@ -384,19 +657,19 @@ export default function ContactsPage() {
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-slate-900 border-slate-700"
|
||||
className="bg-popover border-border"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditForm(contact);
|
||||
}}
|
||||
className="text-slate-300 focus:bg-slate-800 focus:text-white"
|
||||
className="text-popover-foreground focus:bg-muted focus:text-foreground"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuSeparator className="bg-border" />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
@ -420,7 +693,7 @@ export default function ContactsPage() {
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Showing {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalCount)} of{' '}
|
||||
{totalCount}
|
||||
</p>
|
||||
@ -430,11 +703,11 @@ export default function ContactsPage() {
|
||||
size="icon-sm"
|
||||
disabled={!hasPrev}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
className="border-slate-700 text-slate-400 hover:bg-slate-800 hover:text-white disabled:opacity-30"
|
||||
className="border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-slate-400 px-2">
|
||||
<span className="text-xs text-muted-foreground px-2">
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
@ -442,7 +715,7 @@ export default function ContactsPage() {
|
||||
size="icon-sm"
|
||||
disabled={!hasNext}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
className="border-slate-700 text-slate-400 hover:bg-slate-800 hover:text-white disabled:opacity-30"
|
||||
className="border-border text-muted-foreground hover:bg-muted hover:text-foreground disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
@ -491,22 +764,22 @@ export default function ContactsPage() {
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-sm">
|
||||
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Delete Contact</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogTitle className="text-popover-foreground">Delete Contact</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="text-slate-200 font-medium">
|
||||
<span className="text-popover-foreground font-medium">
|
||||
{deleteTarget?.name || deleteTarget?.phone}
|
||||
</span>
|
||||
? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<DialogFooter className="bg-popover border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteConfirmOpen(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@ -521,6 +794,41 @@ export default function ContactsPage() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Bulk Delete Confirmation */}
|
||||
<Dialog open={bulkDeleteOpen} onOpenChange={setBulkDeleteOpen}>
|
||||
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-popover-foreground">
|
||||
Delete {selected.size} {selected.size === 1 ? 'Contact' : 'Contacts'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="text-popover-foreground font-medium">
|
||||
{selected.size} {selected.size === 1 ? 'contact' : 'contacts'}
|
||||
</span>
|
||||
? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="bg-popover border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setBulkDeleteOpen(false)}
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleBulkDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting && <Loader2 className="size-4 animate-spin" />}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
|
||||
import { AuthProvider, useAuth } from "@/hooks/use-auth";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { PresenceHeartbeat } from "@/components/presence/presence-heartbeat";
|
||||
|
||||
// Auth-gated dashboard shell. Extracted from the layout so the layout
|
||||
// itself can stay a server component and export metadata (noindex) —
|
||||
@ -27,10 +28,10 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-slate-950">
|
||||
<div className="flex h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<p className="text-sm text-slate-400">Loading...</p>
|
||||
<p className="text-sm text-muted-foreground">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -39,7 +40,10 @@ function DashboardShellInner({ children }: { children: React.ReactNode }) {
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-slate-950">
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Reports this tab's online/away presence once we know a user is
|
||||
signed in. Headless — renders nothing. */}
|
||||
<PresenceHeartbeat />
|
||||
<Sidebar open={sidebarOpen} onClose={closeSidebar} />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header onOpenSidebar={() => setSidebarOpen(true)} />
|
||||
|
||||
@ -122,8 +122,8 @@ export default function DashboardPage() {
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<h1 className="text-2xl font-bold text-foreground">Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Live analytics across conversations, contacts, deals, broadcasts, and automations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -65,14 +65,14 @@ export default function FlowEditorPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (notFound || !flow) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<p className="text-sm text-slate-400">Flow not found.</p>
|
||||
<p className="text-sm text-muted-foreground">Flow not found.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/flows")}
|
||||
|
||||
@ -68,7 +68,7 @@ const STATUS_META: Record<
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
classes: "border-slate-700 bg-slate-800 text-slate-300",
|
||||
classes: "border-border bg-muted text-muted-foreground",
|
||||
icon: CircleCheck,
|
||||
},
|
||||
handed_off: {
|
||||
@ -78,12 +78,12 @@ const STATUS_META: Record<
|
||||
},
|
||||
timed_out: {
|
||||
label: "Timed out",
|
||||
classes: "border-slate-700 bg-slate-800/60 text-slate-400",
|
||||
classes: "border-border bg-muted/60 text-muted-foreground",
|
||||
icon: Clock,
|
||||
},
|
||||
paused_by_agent: {
|
||||
label: "Paused by agent",
|
||||
classes: "border-slate-700 bg-slate-800 text-slate-300",
|
||||
classes: "border-border bg-muted text-muted-foreground",
|
||||
icon: PauseCircle,
|
||||
},
|
||||
failed: {
|
||||
@ -151,14 +151,14 @@ export default function FlowRunsPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (notFound || !flow) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3">
|
||||
<p className="text-sm text-slate-400">Flow not found.</p>
|
||||
<p className="text-sm text-muted-foreground">Flow not found.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/flows")}
|
||||
@ -175,19 +175,19 @@ export default function FlowRunsPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push(`/flows/${flow.id}`)}
|
||||
className="mb-2 inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300"
|
||||
className="mb-2 inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
{flow.name}
|
||||
</button>
|
||||
<h1 className="text-xl font-semibold text-white">Runs</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<h1 className="text-xl font-semibold text-foreground">Runs</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
The 50 most recent times this flow ran. Expand a row to see the engine's
|
||||
per-step log.
|
||||
</p>
|
||||
|
||||
{runs.length === 0 ? (
|
||||
<div className="mt-6 rounded-lg border border-dashed border-slate-700 bg-slate-900/50 px-6 py-12 text-center text-sm text-slate-400">
|
||||
<div className="mt-6 rounded-lg border border-dashed border-border bg-card/50 px-6 py-12 text-center text-sm text-muted-foreground">
|
||||
No runs yet. Trigger the flow from a personal WhatsApp number to see
|
||||
it appear here.
|
||||
</div>
|
||||
@ -229,20 +229,20 @@ function RunCard({
|
||||
})
|
||||
: null;
|
||||
return (
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900">
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-slate-500" />
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-slate-500" />
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-white">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{contactLabel}
|
||||
</span>
|
||||
<Badge variant="outline" className={cn("gap-1", meta.classes)}>
|
||||
@ -250,12 +250,12 @@ function RunCard({
|
||||
{meta.label}
|
||||
</Badge>
|
||||
{run.status === "active" && run.current_node_key && (
|
||||
<code className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400">
|
||||
<code className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
|
||||
at {run.current_node_key}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-slate-500">
|
||||
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span>Started {format(new Date(run.started_at), "PP p")}</span>
|
||||
{run.reprompt_count > 0 && (
|
||||
<span>· {run.reprompt_count} re-prompts</span>
|
||||
@ -265,20 +265,20 @@ function RunCard({
|
||||
</div>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-800 px-4 py-3">
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
{Object.keys(run.vars).length > 0 && (
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer text-xs text-slate-400">
|
||||
<summary className="cursor-pointer text-xs text-muted-foreground">
|
||||
Captured vars ({Object.keys(run.vars).length})
|
||||
</summary>
|
||||
<pre className="mt-2 overflow-x-auto rounded-md bg-slate-950 p-2 text-[11px] text-slate-300">
|
||||
<pre className="mt-2 overflow-x-auto rounded-md bg-background p-2 text-[11px] text-muted-foreground">
|
||||
{JSON.stringify(run.vars, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
<div className="flex flex-col gap-1">
|
||||
{events.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No events recorded for this run.
|
||||
</p>
|
||||
) : (
|
||||
@ -293,33 +293,33 @@ function RunCard({
|
||||
|
||||
const EVENT_COLOR: Record<string, string> = {
|
||||
started: "text-emerald-300",
|
||||
node_entered: "text-slate-300",
|
||||
node_entered: "text-muted-foreground",
|
||||
message_sent: "text-sky-300",
|
||||
reply_received: "text-primary",
|
||||
fallback_fired: "text-amber-300",
|
||||
handoff: "text-amber-300",
|
||||
timeout: "text-slate-500",
|
||||
timeout: "text-muted-foreground",
|
||||
error: "text-red-300",
|
||||
completed: "text-emerald-300",
|
||||
};
|
||||
|
||||
function EventLine({ ev }: { ev: EventRow }) {
|
||||
const cls = EVENT_COLOR[ev.event_type] ?? "text-slate-400";
|
||||
const cls = EVENT_COLOR[ev.event_type] ?? "text-muted-foreground";
|
||||
return (
|
||||
<div className="flex items-start gap-2 rounded-md px-2 py-1 text-xs">
|
||||
<span className="w-32 shrink-0 text-[10px] text-slate-500">
|
||||
<span className="w-32 shrink-0 text-[10px] text-muted-foreground">
|
||||
{format(new Date(ev.created_at), "HH:mm:ss")}
|
||||
</span>
|
||||
<span className={cn("w-32 shrink-0 font-mono text-[10px]", cls)}>
|
||||
{ev.event_type}
|
||||
</span>
|
||||
{ev.node_key && (
|
||||
<code className="shrink-0 rounded bg-slate-800 px-1 py-0.5 text-[10px] text-slate-400">
|
||||
<code className="shrink-0 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
|
||||
{ev.node_key}
|
||||
</code>
|
||||
)}
|
||||
{Object.keys(ev.payload).length > 0 && (
|
||||
<span className="min-w-0 truncate text-[10px] text-slate-500">
|
||||
<span className="min-w-0 truncate text-[10px] text-muted-foreground">
|
||||
{summarizePayload(ev.payload)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@ -61,9 +61,9 @@ const STATUS_LABELS: Record<FlowRow["status"], string> = {
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<FlowRow["status"], string> = {
|
||||
draft: "border-slate-700 bg-slate-800 text-slate-300",
|
||||
draft: "border-border bg-muted text-muted-foreground",
|
||||
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
|
||||
archived: "border-slate-700 bg-slate-800/50 text-slate-500",
|
||||
archived: "border-border bg-muted/50 text-muted-foreground",
|
||||
};
|
||||
|
||||
interface TemplateSummary {
|
||||
@ -194,7 +194,7 @@ export default function FlowsPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-slate-500" />
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -204,12 +204,12 @@ export default function FlowsPage() {
|
||||
<header className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold text-white">Flows</h1>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Flows</h1>
|
||||
<span className="inline-flex items-center rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-300">
|
||||
Beta
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Build branching, button-driven WhatsApp conversations. Useful for
|
||||
menus, FAQs, and triage before a human steps in.
|
||||
</p>
|
||||
@ -247,17 +247,17 @@ export default function FlowsPage() {
|
||||
`sm:max-w-sm` baked into its default classes. Without the
|
||||
sm: prefix our override applies at base only and the
|
||||
sm-scoped 384px wins at every real desktop breakpoint. */}
|
||||
<DialogContent className="sm:max-w-4xl bg-slate-900 text-slate-100">
|
||||
<DialogContent className="sm:max-w-4xl bg-popover text-popover-foreground">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create a new flow</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Start from a template or build from scratch.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{templates.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Start from a template
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@ -269,16 +269,16 @@ export default function FlowsPage() {
|
||||
type="button"
|
||||
onClick={() => handleUseTemplate(t.slug)}
|
||||
disabled={creating}
|
||||
className="flex flex-col gap-2.5 rounded-lg border border-slate-800 bg-slate-950 p-4 text-left transition-colors hover:border-primary/40 hover:bg-slate-800 disabled:opacity-50"
|
||||
className="flex flex-col gap-2.5 rounded-lg border border-border bg-background p-4 text-left transition-colors hover:border-primary/40 hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Icon className="h-5 w-5 text-primary" />
|
||||
<span className="text-sm font-semibold text-white">
|
||||
<span className="text-sm font-semibold text-popover-foreground">
|
||||
{t.name}
|
||||
</span>
|
||||
<span className="text-xs leading-relaxed text-slate-400">
|
||||
<span className="text-xs leading-relaxed text-muted-foreground">
|
||||
{t.description}
|
||||
</span>
|
||||
<span className="mt-auto border-t border-slate-800 pt-2 text-[11px] text-slate-500">
|
||||
<span className="mt-auto border-t border-border pt-2 text-[11px] text-muted-foreground">
|
||||
{t.node_count} {t.node_count === 1 ? "node" : "nodes"}
|
||||
</span>
|
||||
</button>
|
||||
@ -288,15 +288,15 @@ export default function FlowsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 border-t border-slate-800 pt-4">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
<p className="text-xs uppercase tracking-wide text-muted-foreground">
|
||||
Or start blank
|
||||
</p>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="e.g. Welcome menu"
|
||||
className="bg-slate-800"
|
||||
className="bg-muted"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreate();
|
||||
}}
|
||||
@ -330,14 +330,14 @@ function EmptyState({
|
||||
canCreate: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-slate-700 bg-slate-900/50 px-6 py-16 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-slate-800">
|
||||
<Workflow className="h-6 w-6 text-slate-500" />
|
||||
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed border-border bg-card/50 px-6 py-16 text-center">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
|
||||
<Workflow className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-base font-medium text-white">
|
||||
<h2 className="mt-4 text-base font-medium text-foreground">
|
||||
No flows yet
|
||||
</h2>
|
||||
<p className="mt-1 max-w-md text-sm text-slate-400">
|
||||
<p className="mt-1 max-w-md text-sm text-muted-foreground">
|
||||
Build your first conversation — a welcome menu, an order lookup, an FAQ
|
||||
bot. Customers tap buttons; the bot routes them to the right answer (or
|
||||
the right agent).
|
||||
@ -372,11 +372,11 @@ function FlowCard({
|
||||
? Archive
|
||||
: PauseCircle;
|
||||
return (
|
||||
<div className="flex flex-col rounded-lg border border-slate-800 bg-slate-900 p-4 transition-colors hover:border-slate-700">
|
||||
<div className="flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:border-border">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Workflow className="h-4 w-4 shrink-0 text-primary" />
|
||||
<h3 className="truncate text-sm font-semibold text-white">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground">
|
||||
{flow.name}
|
||||
</h3>
|
||||
</div>
|
||||
@ -392,18 +392,18 @@ function FlowCard({
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 line-clamp-2 text-xs text-slate-400">
|
||||
<p className="mt-2 line-clamp-2 text-xs text-muted-foreground">
|
||||
{flow.description || triggerSummary}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3 text-[11px] text-slate-500">
|
||||
<div className="mt-4 flex items-center gap-3 text-[11px] text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
{flow.execution_count} {flow.execution_count === 1 ? "run" : "runs"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center justify-end gap-2 border-t border-slate-800 pt-3">
|
||||
<div className="mt-4 flex items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<Button variant="ghost" size="sm" onClick={onEdit}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
|
||||
@ -12,6 +12,10 @@ import { toast } from "sonner";
|
||||
import { WifiOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Remembers the agent's show/hide choice for the desktop contact panel
|
||||
// across reloads and sessions (device-scoped, like the theme prefs).
|
||||
const CONTACT_PANEL_STORAGE_KEY = "wacrm:inbox:contact-panel-open";
|
||||
|
||||
export default function InboxPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@ -39,6 +43,36 @@ export default function InboxPage() {
|
||||
*/
|
||||
const [resyncToken, setResyncToken] = useState(0);
|
||||
|
||||
/**
|
||||
* Whether the desktop contact sidebar (tags / deals / notes) is shown.
|
||||
* Defaults to `true` (the historical behaviour) and is restored from
|
||||
* localStorage after mount. We deliberately do NOT read localStorage in
|
||||
* the initializer: the server renders with `true`, so reading a stored
|
||||
* `false` synchronously would produce a hydration mismatch. The effect
|
||||
* below reconciles to the stored value right after mount instead.
|
||||
*/
|
||||
const [contactPanelOpen, setContactPanelOpen] = useState(true);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(CONTACT_PANEL_STORAGE_KEY);
|
||||
if (stored !== null) setContactPanelOpen(stored === "true");
|
||||
} catch {
|
||||
// localStorage can throw in private-browsing / sandboxed contexts.
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleContactPanel = useCallback(() => {
|
||||
setContactPanelOpen((prev) => {
|
||||
const next = !prev;
|
||||
try {
|
||||
localStorage.setItem(CONTACT_PANEL_STORAGE_KEY, String(next));
|
||||
} catch {
|
||||
// Persistence is best-effort; ignore storage failures.
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Fire the deep-link auto-select exactly once per URL — subsequent
|
||||
// list refreshes (realtime, manual refetch) must not snap the user
|
||||
// back to the deep-linked conversation if they've already clicked
|
||||
@ -570,13 +604,20 @@ export default function InboxPage() {
|
||||
onBack={handleCloseConversation}
|
||||
resyncToken={resyncToken}
|
||||
onRefresh={handleManualRefresh}
|
||||
contactPanelOpen={contactPanelOpen}
|
||||
onToggleContactPanel={handleToggleContactPanel}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Contact sidebar — desktop only. */}
|
||||
{/* Right panel: Contact sidebar — desktop only, and only when the
|
||||
agent hasn't collapsed it via the thread-header toggle (#258).
|
||||
On mobile it's always hidden (the `lg:block` below), so the
|
||||
toggle — which is itself desktop-only — never affects it. */}
|
||||
{contactPanelOpen && (
|
||||
<div className="hidden lg:block">
|
||||
<ContactSidebar contact={activeContact} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -299,12 +299,12 @@ export default function PipelinesPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-8 w-48 animate-pulse rounded bg-slate-800" />
|
||||
<div className="h-9 w-28 animate-pulse rounded-lg bg-slate-800" />
|
||||
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
|
||||
<div className="h-9 w-28 animate-pulse rounded-lg bg-muted" />
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<div key={i} className="h-96 w-72 animate-pulse rounded-xl bg-slate-800/50" />
|
||||
<div key={i} className="h-96 w-72 animate-pulse rounded-xl bg-muted/50" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@ -319,20 +319,20 @@ export default function PipelinesPage() {
|
||||
{/* Pipeline selector dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white hover:bg-slate-800 transition-colors data-[popup-open]:bg-slate-800"
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground hover:bg-muted transition-colors data-[popup-open]:bg-muted"
|
||||
>
|
||||
<GitBranch className="h-4 w-4 text-primary" />
|
||||
<span className="font-semibold">
|
||||
{selectedPipeline?.name ?? "Select Pipeline"}
|
||||
</span>
|
||||
<ChevronDown className="h-4 w-4 text-slate-400" />
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-64 border-slate-700 bg-slate-900 text-slate-200"
|
||||
className="w-64 border-border bg-popover text-popover-foreground"
|
||||
>
|
||||
{pipelines.length === 0 && (
|
||||
<DropdownMenuItem disabled className="text-slate-500">
|
||||
<DropdownMenuItem disabled className="text-muted-foreground">
|
||||
No pipelines yet
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
@ -343,18 +343,18 @@ export default function PipelinesPage() {
|
||||
className={
|
||||
p.id === selectedPipelineId
|
||||
? "text-primary"
|
||||
: "text-slate-300"
|
||||
: "text-popover-foreground"
|
||||
}
|
||||
>
|
||||
<GitBranch className="mr-2 h-3.5 w-3.5" />
|
||||
{p.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuSeparator className="bg-border" />
|
||||
{selectedPipeline && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="text-slate-300"
|
||||
className="text-popover-foreground"
|
||||
>
|
||||
<Settings className="mr-2 h-3.5 w-3.5" />
|
||||
Manage Pipelines
|
||||
@ -370,7 +370,7 @@ export default function PipelinesPage() {
|
||||
canAct={canEditSettings}
|
||||
gateReason="create pipelines"
|
||||
onClick={() => setNewPipelineOpen(true)}
|
||||
className="border-slate-700 bg-slate-900 text-slate-200 hover:bg-slate-800"
|
||||
className="border-border bg-card text-foreground hover:bg-muted"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
Add Pipeline
|
||||
@ -390,12 +390,12 @@ export default function PipelinesPage() {
|
||||
|
||||
{/* Board */}
|
||||
{pipelines.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-700 py-20">
|
||||
<GitBranch className="h-12 w-12 text-slate-600" />
|
||||
<h3 className="mt-4 text-lg font-medium text-white">
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20">
|
||||
<GitBranch className="h-12 w-12 text-muted-foreground" />
|
||||
<h3 className="mt-4 text-lg font-medium text-foreground">
|
||||
No pipelines yet
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Create a pipeline to start tracking deals
|
||||
</p>
|
||||
<GatedButton
|
||||
@ -423,30 +423,30 @@ export default function PipelinesPage() {
|
||||
|
||||
{/* New Pipeline Dialog */}
|
||||
<Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}>
|
||||
<DialogContent className="sm:max-w-sm bg-slate-900 border-slate-700">
|
||||
<DialogContent className="sm:max-w-sm bg-popover border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">New Pipeline</DialogTitle>
|
||||
<DialogTitle className="text-popover-foreground">New Pipeline</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Label className="text-slate-300">Pipeline Name</Label>
|
||||
<Label className="text-muted-foreground">Pipeline Name</Label>
|
||||
<Input
|
||||
value={newPipelineName}
|
||||
onChange={(e) => setNewPipelineName(e.target.value)}
|
||||
placeholder="e.g., Enterprise Sales"
|
||||
className="mt-2 bg-slate-800 border-slate-700 text-white"
|
||||
className="mt-2 bg-muted border-border text-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreatePipeline();
|
||||
}}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Default stages (New Lead → Won) will be created automatically.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
||||
<DialogFooter className="bg-popover/50 border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setNewPipelineOpen(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@ -1,179 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
Settings,
|
||||
MessageSquare,
|
||||
Tag,
|
||||
User,
|
||||
Palette,
|
||||
UsersRound,
|
||||
Coins,
|
||||
SlidersHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { useCan } from '@/hooks/use-can';
|
||||
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { SettingsRail } from '@/components/settings/settings-rail';
|
||||
import { SettingsOverview } from '@/components/settings/settings-overview';
|
||||
import { ProfileForm } from '@/components/settings/profile-form';
|
||||
import { SecurityPanel } from '@/components/settings/security-panel';
|
||||
import { AppearancePanel } from '@/components/settings/appearance-panel';
|
||||
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
||||
import { TemplateManager } from '@/components/settings/template-manager';
|
||||
import { TagManager } from '@/components/settings/tag-manager';
|
||||
import { ProfileForm } from '@/components/settings/profile-form';
|
||||
import { PasswordForm } from '@/components/settings/password-form';
|
||||
import { SessionsCard } from '@/components/settings/sessions-card';
|
||||
import { AppearancePanel } from '@/components/settings/appearance-panel';
|
||||
import { MembersTab } from '@/components/settings/members-tab';
|
||||
import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel';
|
||||
import { DealsSettings } from '@/components/settings/deals-settings';
|
||||
import { CustomFieldsSettings } from '@/components/settings/custom-fields-settings';
|
||||
|
||||
const TAB_VALUES = [
|
||||
'profile',
|
||||
'whatsapp',
|
||||
'templates',
|
||||
'tags',
|
||||
'custom-fields',
|
||||
'deals',
|
||||
'appearance',
|
||||
'members',
|
||||
] as const;
|
||||
type TabValue = (typeof TAB_VALUES)[number];
|
||||
|
||||
function isTabValue(v: string | null): v is TabValue {
|
||||
return !!v && (TAB_VALUES as readonly string[]).includes(v);
|
||||
}
|
||||
import { MembersTab } from '@/components/settings/members-tab';
|
||||
import { ApiKeysSettings } from '@/components/settings/api-keys-settings';
|
||||
import {
|
||||
resolveSection,
|
||||
type SettingsSection,
|
||||
} from '@/components/settings/settings-sections';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { defaultCurrency } = useAuth();
|
||||
const { mode } = useTheme();
|
||||
|
||||
// Custom-field definitions are account-wide config, so editing them is
|
||||
// admin+ only — mirror the gate on the Contacts page. The `custom_fields`
|
||||
// RLS rejects non-admin writes regardless.
|
||||
const canEditSettings = useCan('edit-settings');
|
||||
// The URL (`?tab=`) is the single source of truth for the active
|
||||
// section — deep-linkable, and it keeps the existing links in the
|
||||
// app sidebar/header working. Legacy tab values (tags, custom-fields)
|
||||
// resolve onto their new home; unknown/empty → the Overview landing.
|
||||
const section = resolveSection(searchParams.get('tab'));
|
||||
|
||||
// The URL is the single source of truth for the active tab — no
|
||||
// local state, no sync effect. A previous revision duplicated this
|
||||
// into `useState` + a sync effect, which tripped React 19's
|
||||
// set-state-in-effect rule and was also redundant.
|
||||
const queryTab = searchParams.get('tab');
|
||||
// Deep-linking to the admin-only tab as a non-admin falls back to profile
|
||||
// rather than landing on a tab with no trigger or content.
|
||||
const resolved: TabValue = isTabValue(queryTab) ? queryTab : 'profile';
|
||||
const tab: TabValue =
|
||||
resolved === 'custom-fields' && !canEditSettings ? 'profile' : resolved;
|
||||
|
||||
const onChange = (next: TabValue) => {
|
||||
const go = (next: SettingsSection) => {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('tab', next);
|
||||
router.replace(`/settings?${params.toString()}`, { scroll: false });
|
||||
};
|
||||
|
||||
// Cheap, fetch-free rail hints. The Overview landing carries the
|
||||
// full live status/counts; the rail just surfaces the two that are
|
||||
// already in context.
|
||||
const hints: Partial<Record<SettingsSection, ReactNode>> = useMemo(
|
||||
() => ({
|
||||
appearance: mode.charAt(0).toUpperCase() + mode.slice(1),
|
||||
deals: defaultCurrency,
|
||||
}),
|
||||
[mode, defaultCurrency],
|
||||
);
|
||||
|
||||
const panel: Record<SettingsSection, ReactNode> = {
|
||||
overview: <SettingsOverview onSelect={go} />,
|
||||
profile: <ProfileForm />,
|
||||
security: <SecurityPanel />,
|
||||
appearance: <AppearancePanel />,
|
||||
whatsapp: <WhatsAppConfig />,
|
||||
templates: <TemplateManager />,
|
||||
fields: <FieldsAndTagsPanel />,
|
||||
deals: <DealsSettings />,
|
||||
members: <MembersTab />,
|
||||
api: <ApiKeysSettings />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Manage your profile, WhatsApp® integration, message templates, and
|
||||
tags.
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
||||
Settings
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Everything in one place — your account and your workspace. Pick a
|
||||
section to manage it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => onChange(v as TabValue)}>
|
||||
<TabsList className="border border-slate-700 bg-slate-900">
|
||||
<TabsTrigger
|
||||
value="profile"
|
||||
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||
>
|
||||
<User className="size-4" />
|
||||
Profile
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="whatsapp"
|
||||
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
WhatsApp Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="templates"
|
||||
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||
>
|
||||
<MessageSquare className="size-4" />
|
||||
Templates
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="tags"
|
||||
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||
>
|
||||
<Tag className="size-4" />
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
{canEditSettings && (
|
||||
<TabsTrigger
|
||||
value="custom-fields"
|
||||
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||
>
|
||||
<SlidersHorizontal className="size-4" />
|
||||
Custom Fields
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="deals"
|
||||
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||
>
|
||||
<Coins className="size-4" />
|
||||
Deals
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="appearance"
|
||||
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||
>
|
||||
<Palette className="size-4" />
|
||||
Appearance
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="members"
|
||||
className="data-active:text-primary text-slate-400 data-active:bg-slate-800"
|
||||
>
|
||||
<UsersRound className="size-4" />
|
||||
Members
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="profile" className="space-y-6">
|
||||
<ProfileForm />
|
||||
<PasswordForm />
|
||||
<SessionsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="whatsapp">
|
||||
<WhatsAppConfig />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="templates">
|
||||
<TemplateManager />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags">
|
||||
<TagManager />
|
||||
</TabsContent>
|
||||
|
||||
{canEditSettings && (
|
||||
<TabsContent value="custom-fields">
|
||||
<CustomFieldsSettings />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="deals">
|
||||
<DealsSettings />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="appearance">
|
||||
<AppearancePanel />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="members">
|
||||
<MembersTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[236px_minmax(0,1fr)] lg:items-start">
|
||||
<SettingsRail active={section} onSelect={go} hints={hints} />
|
||||
<div className="min-w-0">{panel[section]}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
71
src/app/api/account/api-keys/[id]/route.ts
Normal file
71
src/app/api/account/api-keys/[id]/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
159
src/app/api/account/api-keys/route.ts
Normal file
159
src/app/api/account/api-keys/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
31
src/app/api/v1/me/route.ts
Normal file
31
src/app/api/v1/me/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
246
src/app/api/whatsapp/send/route.test.ts
Normal file
246
src/app/api/whatsapp/send/route.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -1,6 +1,11 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||
import {
|
||||
sendTextMessage,
|
||||
sendTemplateMessage,
|
||||
sendMediaMessage,
|
||||
type MediaKind,
|
||||
} from '@/lib/whatsapp/meta-api'
|
||||
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
|
||||
import { supabaseAdmin } from '@/lib/flows/admin-client'
|
||||
import {
|
||||
@ -59,10 +64,15 @@ export async function POST(request: Request) {
|
||||
|
||||
const body = await request.json()
|
||||
const {
|
||||
conversation_id,
|
||||
// `conversation_id` targets an existing thread (inbox). `contact_id`
|
||||
// lets a caller initiate from a contact that may have no conversation
|
||||
// yet (Contact detail → Send template) — we find-or-create one below.
|
||||
conversation_id: conversationIdInput,
|
||||
contact_id,
|
||||
message_type,
|
||||
content_text,
|
||||
media_url,
|
||||
filename,
|
||||
template_name,
|
||||
template_language,
|
||||
template_params,
|
||||
@ -70,9 +80,27 @@ export async function POST(request: Request) {
|
||||
reply_to_message_id,
|
||||
} = body
|
||||
|
||||
if (!conversation_id || !message_type) {
|
||||
if ((!conversationIdInput && !contact_id) || !message_type) {
|
||||
return NextResponse.json(
|
||||
{ error: 'conversation_id and message_type are required' },
|
||||
{
|
||||
error:
|
||||
'Either conversation_id or contact_id, plus message_type, are required',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Media kinds (image/video/document/audio) are sent to Meta via a
|
||||
// public URL the composer already uploaded to the chat-media bucket.
|
||||
const MEDIA_KINDS = ['image', 'video', 'document', 'audio'] as const
|
||||
const isMediaKind = (MEDIA_KINDS as readonly string[]).includes(message_type)
|
||||
|
||||
// Reject anything outside the known set up front rather than letting
|
||||
// an unknown type fall through to the text path with empty content.
|
||||
const VALID_MESSAGE_TYPES = ['text', 'template', ...MEDIA_KINDS] as const
|
||||
if (!(VALID_MESSAGE_TYPES as readonly string[]).includes(message_type)) {
|
||||
return NextResponse.json(
|
||||
{ error: `Unsupported message_type "${message_type}"` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
@ -91,20 +119,89 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch conversation and contact
|
||||
const { data: conversation, error: convError } = await supabase
|
||||
if (isMediaKind && !media_url) {
|
||||
return NextResponse.json(
|
||||
{ error: `media_url is required for ${message_type} messages` },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Meta caps media captions at 1024 chars; reject before the upload is
|
||||
// wasted at the Meta call. (Audio carries no caption — see meta-api.)
|
||||
if (
|
||||
isMediaKind &&
|
||||
message_type !== 'audio' &&
|
||||
typeof content_text === 'string' &&
|
||||
content_text.length > 1024
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Caption exceeds the 1024-character limit' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve the target conversation. With `conversation_id` we load the
|
||||
// existing thread; with `contact_id` we find-or-create one for the
|
||||
// contact so a business-initiated template send (Contact detail view)
|
||||
// reuses this whole path — phone variants, send-builder, persistence.
|
||||
let conversation: { id: string; contact?: { id: string; phone?: string } | null } | null = null
|
||||
|
||||
if (conversationIdInput) {
|
||||
const { data, error: convError } = await supabase
|
||||
.from('conversations')
|
||||
.select('*, contact:contacts(*)')
|
||||
.eq('id', conversation_id)
|
||||
.eq('id', conversationIdInput)
|
||||
.eq('account_id', accountId)
|
||||
.single()
|
||||
|
||||
if (convError || !conversation) {
|
||||
if (convError || !data) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Conversation not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
conversation = data
|
||||
} else {
|
||||
// contact_id path: verify the contact is in this account first so a
|
||||
// caller can't open a conversation against someone else's contact.
|
||||
const { data: contactRow, error: contactErr } = await supabase
|
||||
.from('contacts')
|
||||
.select('*')
|
||||
.eq('id', contact_id)
|
||||
.eq('account_id', accountId)
|
||||
.maybeSingle()
|
||||
|
||||
if (contactErr || !contactRow) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Contact not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const resolved = await findOrCreateConversation(
|
||||
supabase,
|
||||
accountId,
|
||||
user.id,
|
||||
contact_id
|
||||
)
|
||||
if (!resolved) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to open a conversation for this contact' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
// The embed may not round-trip on insert; pin the contact we verified.
|
||||
conversation = { ...resolved, contact: resolved.contact ?? contactRow }
|
||||
}
|
||||
|
||||
if (!conversation) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Conversation not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const conversation_id = conversation.id
|
||||
|
||||
const contact = conversation.contact
|
||||
if (!contact?.phone) {
|
||||
@ -246,6 +343,22 @@ export async function POST(request: Request) {
|
||||
})
|
||||
return result.messageId
|
||||
}
|
||||
if (isMediaKind) {
|
||||
// content_text doubles as the caption (ignored for audio inside
|
||||
// sendMediaMessage). filename surfaces in the recipient's chat
|
||||
// for documents only.
|
||||
const result = await sendMediaMessage({
|
||||
phoneNumberId: config.phone_number_id,
|
||||
accessToken,
|
||||
to: phone,
|
||||
kind: message_type as MediaKind,
|
||||
link: media_url,
|
||||
caption: content_text || undefined,
|
||||
filename: filename || undefined,
|
||||
contextMessageId,
|
||||
})
|
||||
return result.messageId
|
||||
}
|
||||
const result = await sendTextMessage({
|
||||
phoneNumberId: config.phone_number_id,
|
||||
accessToken,
|
||||
@ -384,3 +497,45 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type SendSupabase = Awaited<ReturnType<typeof createClient>>
|
||||
|
||||
/**
|
||||
* Return the contact's conversation in this account, creating one if it
|
||||
* doesn't exist yet. Mirrors the webhook's find-or-create so an
|
||||
* inbound-then-outbound (or outbound-first) sequence converges on a single
|
||||
* thread per contact. Runs under the caller's RLS — the conversations_insert
|
||||
* policy requires account agent membership, which the caller already is.
|
||||
*/
|
||||
async function findOrCreateConversation(
|
||||
supabase: SendSupabase,
|
||||
accountId: string,
|
||||
userId: string,
|
||||
contactId: string,
|
||||
) {
|
||||
const { data: existing } = await supabase
|
||||
.from('conversations')
|
||||
.select('*, contact:contacts(*)')
|
||||
.eq('account_id', accountId)
|
||||
.eq('contact_id', contactId)
|
||||
.maybeSingle()
|
||||
|
||||
if (existing) return existing
|
||||
|
||||
const { data: created, error } = await supabase
|
||||
.from('conversations')
|
||||
.insert({
|
||||
account_id: accountId,
|
||||
user_id: userId,
|
||||
contact_id: contactId,
|
||||
})
|
||||
.select('*, contact:contacts(*)')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating conversation for contact send:', error.message)
|
||||
return null
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
type TemplatePayload,
|
||||
} from '@/lib/whatsapp/template-validators'
|
||||
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
|
||||
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
|
||||
|
||||
/**
|
||||
* Per-template lifecycle endpoint.
|
||||
@ -136,8 +137,6 @@ export async function PATCH(
|
||||
)
|
||||
}
|
||||
|
||||
const metaPayload = buildMetaTemplatePayload(payload)
|
||||
|
||||
if (!isDryRun()) {
|
||||
const { data: config, error: configError } = await supabase
|
||||
.from('whatsapp_config')
|
||||
@ -151,6 +150,19 @@ export async function PATCH(
|
||||
)
|
||||
}
|
||||
const accessToken = decrypt(config.access_token)
|
||||
|
||||
// Image headers need a fresh Resumable-Upload handle on every edit
|
||||
// (Meta replaces components wholesale). Derive from header_media_url.
|
||||
try {
|
||||
await ensureImageHeaderHandle(payload, accessToken)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'Header image upload failed.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const metaPayload = buildMetaTemplatePayload(payload)
|
||||
try {
|
||||
await editMessageTemplate({
|
||||
metaTemplateId: existing.meta_template_id,
|
||||
|
||||
@ -8,6 +8,7 @@ import {
|
||||
type TemplatePayload,
|
||||
} from '@/lib/whatsapp/template-validators'
|
||||
import { buildMetaTemplatePayload } from '@/lib/whatsapp/template-components'
|
||||
import { ensureImageHeaderHandle } from '@/lib/whatsapp/template-header-handle'
|
||||
import { normalizeStatus } from '@/lib/whatsapp/template-status-normalize'
|
||||
|
||||
/**
|
||||
@ -137,8 +138,6 @@ export async function POST(request: Request) {
|
||||
)
|
||||
}
|
||||
|
||||
const metaPayload = buildMetaTemplatePayload(payload)
|
||||
|
||||
const dryRun =
|
||||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === 'true' ||
|
||||
process.env.WHATSAPP_TEMPLATES_DRY_RUN === '1'
|
||||
@ -175,6 +174,21 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const accessToken = decrypt(config.access_token)
|
||||
|
||||
// Image headers need a Resumable-Upload handle (Meta rejects a
|
||||
// plain URL at creation). Derive it from header_media_url before
|
||||
// building the payload. Surfaces a 400 with an actionable message
|
||||
// (missing META_APP_ID, unreachable URL, wrong type/size).
|
||||
try {
|
||||
await ensureImageHeaderHandle(payload, accessToken)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : 'Header image upload failed.' },
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
const metaPayload = buildMetaTemplatePayload(payload)
|
||||
try {
|
||||
const meta = await submitMessageTemplate({
|
||||
wabaId: config.waba_id,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { NextResponse, after } from 'next/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
|
||||
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
|
||||
@ -12,6 +12,12 @@ import {
|
||||
isTemplateWebhookField,
|
||||
} from '@/lib/whatsapp/template-webhook'
|
||||
|
||||
// The `after()` callback in POST runs within this route's max duration.
|
||||
// Inbound processing can fan out to per-media Meta verification calls, so
|
||||
// give it headroom beyond the platform default (Vercel clamps this to the
|
||||
// plan's ceiling). Tune as needed.
|
||||
export const maxDuration = 60
|
||||
|
||||
// Lazy-initialized to avoid build-time crash when env vars are missing
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let _adminClient: any = null
|
||||
@ -182,9 +188,26 @@ export async function POST(request: Request) {
|
||||
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Process asynchronously so we can ack Meta within their timeout.
|
||||
processWebhook(body).catch((error) => {
|
||||
// Process AFTER the response so we ack Meta within their ~20s timeout
|
||||
// (a slow ack triggers Meta retries + duplicate inserts), while still
|
||||
// guaranteeing the work runs to completion.
|
||||
//
|
||||
// This MUST use `after()` rather than a detached `processWebhook(body)`
|
||||
// promise: on serverless platforms (we run on Vercel) the function can
|
||||
// be frozen or terminated the moment the response is sent, so a floating
|
||||
// promise's DB writes are not guaranteed to finish. That dropped a
|
||||
// non-deterministic *subset* of inbound messages — contacts/conversations
|
||||
// were created but the message insert never landed, leaving conversations
|
||||
// that show in the inbox with an empty thread, and no logs to explain it
|
||||
// (see issue #301). `after()` hands the callback to the runtime, which
|
||||
// keeps the function alive until it resolves (within the route's
|
||||
// maxDuration).
|
||||
after(async () => {
|
||||
try {
|
||||
await processWebhook(body)
|
||||
} catch (error) {
|
||||
console.error('Error processing webhook:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return NextResponse.json({ status: 'received' }, { status: 200 })
|
||||
|
||||
@ -39,6 +39,15 @@
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
/* Secondary card surface (tile / hover backgrounds) and the accent
|
||||
* extras the Settings redesign leans on. The raw --primary-* vars
|
||||
* already exist per-accent below; these mappings expose them (and
|
||||
* --card-2) as Tailwind utilities: bg-card-2, bg-primary-soft,
|
||||
* bg-primary-soft-2, hover:bg-primary-hover. */
|
||||
--color-card-2: var(--card-2);
|
||||
--color-primary-hover: var(--primary-hover);
|
||||
--color-primary-soft: var(--primary-soft);
|
||||
--color-primary-soft-2: var(--primary-soft-2);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
@ -49,39 +58,43 @@
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* COLOR THEMES
|
||||
* THEMING — two orthogonal dimensions
|
||||
*
|
||||
* 5 dark themes sharing structure, swapping only the primary
|
||||
* accent (and a tiny hue rotation on the slate base). Switched
|
||||
* at runtime by setting `document.documentElement.dataset.theme`
|
||||
* — see ThemeProvider + the inline boot script in layout.tsx.
|
||||
* 1. MODE (light / dark) → neutral surfaces. Selected with
|
||||
* `document.documentElement.dataset.mode`. Defaults to dark.
|
||||
* 2. ACCENT (violet / …) → the primary color only. Selected
|
||||
* with `document.documentElement.dataset.theme`.
|
||||
*
|
||||
* `:root` mirrors Violet so default visitors render correctly
|
||||
* before JS runs. `[data-theme="violet"]` is included for the
|
||||
* explicit-select path (settings UI sets it after a pick).
|
||||
* The two compose: any accent works in either mode. Neutral
|
||||
* tokens (background / card / border / muted / sidebar surfaces /
|
||||
* neutral charts / radius) live in the MODE blocks below; accent
|
||||
* tokens (--primary*, --ring, --chart-1, --sidebar-primary*,
|
||||
* --sidebar-ring) live in the ACCENT blocks. They set disjoint
|
||||
* variables, so cascade order between them doesn't matter.
|
||||
*
|
||||
* The three `--primary-*` extras (hover / soft / soft-2) are
|
||||
* new — used for hovered primary buttons and tinted-primary
|
||||
* surfaces (sidebar active pill, validation badges). Components
|
||||
* that don't use them remain backward-compatible.
|
||||
* `:root` carries the dark-mode + violet defaults so a visitor
|
||||
* renders correctly before JS runs; the boot script in
|
||||
* layout.tsx replays the saved mode + accent before first paint.
|
||||
*
|
||||
* The three `--primary-*` extras (hover / soft / soft-2) feed
|
||||
* hovered primary buttons and tinted-primary surfaces (sidebar
|
||||
* active pill, validation badges).
|
||||
*
|
||||
* Token shapes come straight from the design handoff at
|
||||
* project/Color Themes.html — change one, change the other.
|
||||
* ============================================================ */
|
||||
|
||||
/* ---- MODE: neutral surfaces ---- */
|
||||
|
||||
:root,
|
||||
html[data-theme="violet"] {
|
||||
html[data-mode="dark"] {
|
||||
--background: oklch(0.13 0.01 260);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.18 0.01 260);
|
||||
--card-2: oklch(0.205 0.01 260);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.18 0.01 260);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.526 0.247 293);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary-hover: oklch(0.6 0.22 293);
|
||||
--primary-soft: oklch(0.526 0.247 293 / 0.12);
|
||||
--primary-soft-2: oklch(0.526 0.247 293 / 0.2);
|
||||
--secondary: oklch(0.22 0.01 260);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.22 0.01 260);
|
||||
@ -91,8 +104,6 @@ html[data-theme="violet"] {
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.28 0.01 260);
|
||||
--input: oklch(0.28 0.01 260);
|
||||
--ring: oklch(0.526 0.247 293);
|
||||
--chart-1: oklch(0.526 0.247 293);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
@ -100,159 +111,109 @@ html[data-theme="violet"] {
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.16 0.01 260);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.526 0.247 293);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.22 0.01 260);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.28 0.01 260);
|
||||
}
|
||||
|
||||
html[data-mode="light"] {
|
||||
--background: oklch(0.99 0.002 260);
|
||||
--foreground: oklch(0.21 0.01 260);
|
||||
--card: oklch(1 0 0);
|
||||
--card-2: oklch(0.985 0.002 260);
|
||||
--card-foreground: oklch(0.21 0.01 260);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.21 0.01 260);
|
||||
--secondary: oklch(0.967 0.003 260);
|
||||
--secondary-foreground: oklch(0.25 0.01 260);
|
||||
--muted: oklch(0.967 0.003 260);
|
||||
--muted-foreground: oklch(0.52 0.015 260);
|
||||
--accent: oklch(0.96 0.004 260);
|
||||
--accent-foreground: oklch(0.25 0.01 260);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0.004 260);
|
||||
--input: oklch(0.922 0.004 260);
|
||||
--chart-2: oklch(0.6 0.1 260);
|
||||
--chart-3: oklch(0.7 0.06 260);
|
||||
--chart-4: oklch(0.8 0.04 260);
|
||||
--chart-5: oklch(0.87 0.03 260);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0.002 260);
|
||||
--sidebar-foreground: oklch(0.21 0.01 260);
|
||||
--sidebar-accent: oklch(0.96 0.004 260);
|
||||
--sidebar-accent-foreground: oklch(0.25 0.01 260);
|
||||
--sidebar-border: oklch(0.922 0.004 260);
|
||||
}
|
||||
|
||||
/* ---- ACCENT: primary color ---- */
|
||||
|
||||
:root,
|
||||
html[data-theme="violet"] {
|
||||
--primary: oklch(0.526 0.247 293);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary-hover: oklch(0.6 0.22 293);
|
||||
--primary-soft: oklch(0.526 0.247 293 / 0.12);
|
||||
--primary-soft-2: oklch(0.526 0.247 293 / 0.2);
|
||||
--ring: oklch(0.526 0.247 293);
|
||||
--chart-1: oklch(0.526 0.247 293);
|
||||
--sidebar-primary: oklch(0.526 0.247 293);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-ring: oklch(0.526 0.247 293);
|
||||
}
|
||||
|
||||
html[data-theme="emerald"] {
|
||||
--background: oklch(0.135 0.01 200);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.185 0.01 200);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.185 0.01 200);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.62 0.16 162);
|
||||
--primary-foreground: oklch(0.16 0.02 162);
|
||||
--primary-hover: oklch(0.68 0.15 162);
|
||||
--primary-soft: oklch(0.62 0.16 162 / 0.12);
|
||||
--primary-soft-2: oklch(0.62 0.16 162 / 0.22);
|
||||
--secondary: oklch(0.225 0.01 200);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.225 0.01 200);
|
||||
--muted-foreground: oklch(0.65 0.01 200);
|
||||
--accent: oklch(0.225 0.01 200);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.285 0.01 200);
|
||||
--input: oklch(0.285 0.01 200);
|
||||
--ring: oklch(0.62 0.16 162);
|
||||
--chart-1: oklch(0.62 0.16 162);
|
||||
--chart-2: oklch(0.7 0.14 195);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.165 0.01 200);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.62 0.16 162);
|
||||
--sidebar-primary-foreground: oklch(0.16 0.02 162);
|
||||
--sidebar-accent: oklch(0.225 0.01 200);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.285 0.01 200);
|
||||
--sidebar-ring: oklch(0.62 0.16 162);
|
||||
}
|
||||
|
||||
html[data-theme="cobalt"] {
|
||||
--background: oklch(0.13 0.015 252);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.18 0.015 252);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.18 0.015 252);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.585 0.2 254);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary-hover: oklch(0.66 0.18 254);
|
||||
--primary-soft: oklch(0.585 0.2 254 / 0.12);
|
||||
--primary-soft-2: oklch(0.585 0.2 254 / 0.22);
|
||||
--secondary: oklch(0.22 0.015 252);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.22 0.015 252);
|
||||
--muted-foreground: oklch(0.65 0.012 252);
|
||||
--accent: oklch(0.22 0.015 252);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.28 0.015 252);
|
||||
--input: oklch(0.28 0.015 252);
|
||||
--ring: oklch(0.585 0.2 254);
|
||||
--chart-1: oklch(0.585 0.2 254);
|
||||
--chart-2: oklch(0.7 0.15 220);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.16 0.015 252);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.585 0.2 254);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.22 0.015 252);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.28 0.015 252);
|
||||
--sidebar-ring: oklch(0.585 0.2 254);
|
||||
}
|
||||
|
||||
html[data-theme="amber"] {
|
||||
--background: oklch(0.135 0.012 60);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.185 0.012 60);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.185 0.012 60);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.745 0.16 65);
|
||||
--primary-foreground: oklch(0.18 0.03 65);
|
||||
--primary-hover: oklch(0.8 0.15 65);
|
||||
--primary-soft: oklch(0.745 0.16 65 / 0.12);
|
||||
--primary-soft-2: oklch(0.745 0.16 65 / 0.22);
|
||||
--secondary: oklch(0.225 0.012 60);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.225 0.012 60);
|
||||
--muted-foreground: oklch(0.66 0.01 60);
|
||||
--accent: oklch(0.225 0.012 60);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.285 0.012 60);
|
||||
--input: oklch(0.285 0.012 60);
|
||||
--ring: oklch(0.745 0.16 65);
|
||||
--chart-1: oklch(0.745 0.16 65);
|
||||
--chart-2: oklch(0.7 0.15 35);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.165 0.012 60);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.745 0.16 65);
|
||||
--sidebar-primary-foreground: oklch(0.18 0.03 65);
|
||||
--sidebar-accent: oklch(0.225 0.012 60);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.285 0.012 60);
|
||||
--sidebar-ring: oklch(0.745 0.16 65);
|
||||
}
|
||||
|
||||
html[data-theme="rose"] {
|
||||
--background: oklch(0.13 0.012 15);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.18 0.012 15);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.18 0.012 15);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.645 0.22 16);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--primary-hover: oklch(0.71 0.2 16);
|
||||
--primary-soft: oklch(0.645 0.22 16 / 0.12);
|
||||
--primary-soft-2: oklch(0.645 0.22 16 / 0.22);
|
||||
--secondary: oklch(0.22 0.012 15);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.22 0.012 15);
|
||||
--muted-foreground: oklch(0.65 0.01 15);
|
||||
--accent: oklch(0.22 0.012 15);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.28 0.012 15);
|
||||
--input: oklch(0.28 0.012 15);
|
||||
--ring: oklch(0.645 0.22 16);
|
||||
--chart-1: oklch(0.645 0.22 16);
|
||||
--chart-2: oklch(0.7 0.18 340);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.16 0.012 15);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.645 0.22 16);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.22 0.012 15);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.28 0.012 15);
|
||||
--sidebar-ring: oklch(0.645 0.22 16);
|
||||
}
|
||||
|
||||
|
||||
@ -220,10 +220,10 @@ export default function JoinPage() {
|
||||
// ----- Loading state (peek pending OR auth not yet resolved) -----
|
||||
if (peek === null || authedUserId === undefined) {
|
||||
return (
|
||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||
<Card className="w-full max-w-md border-border bg-card">
|
||||
<CardContent className="flex flex-col items-center gap-3 py-12">
|
||||
<Loader2 className="size-6 animate-spin text-primary" />
|
||||
<p className="text-sm text-slate-400">Verifying invitation…</p>
|
||||
<p className="text-sm text-muted-foreground">Verifying invitation…</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@ -233,13 +233,13 @@ export default function JoinPage() {
|
||||
if (!peek.ok) {
|
||||
const copy = FAIL_COPY[peek.reason];
|
||||
return (
|
||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||
<Card className="w-full max-w-md border-border bg-card">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-red-500/10">
|
||||
<MailX className="h-6 w-6 text-red-400" />
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">{copy.title}</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
<CardTitle className="text-xl text-foreground">{copy.title}</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
{copy.body}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@ -262,7 +262,7 @@ export default function JoinPage() {
|
||||
<Link href="/signup">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
Create a new account instead
|
||||
</Button>
|
||||
@ -278,7 +278,7 @@ export default function JoinPage() {
|
||||
<Link href="/login">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
@ -296,13 +296,13 @@ export default function JoinPage() {
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-primary/10">
|
||||
<UsersRound className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">
|
||||
<CardTitle className="text-xl text-foreground">
|
||||
You're invited to{' '}
|
||||
<span className="text-primary">{peek.account_name}</span>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
You'll join as{' '}
|
||||
<span className="inline-flex items-center gap-1 text-white">
|
||||
<span className="inline-flex items-center gap-1 text-foreground">
|
||||
<ShieldCheck className="size-3.5 text-primary" />
|
||||
{ROLE_LABEL[peek.role]}
|
||||
</span>
|
||||
@ -321,7 +321,7 @@ export default function JoinPage() {
|
||||
if (authedUserId) {
|
||||
return (
|
||||
<>
|
||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||
<Card className="w-full max-w-md border-border bg-card">
|
||||
{inviteHeader}
|
||||
<CardContent className="flex flex-col gap-3">
|
||||
<Button
|
||||
@ -341,9 +341,9 @@ export default function JoinPage() {
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<p className="text-center text-xs text-slate-500">
|
||||
<p className="text-center text-xs text-muted-foreground">
|
||||
Accepting moves your login into{' '}
|
||||
<span className="text-slate-400">{peek.account_name}</span>. Your
|
||||
<span className="text-muted-foreground">{peek.account_name}</span>. Your
|
||||
empty personal account from signup will be cleaned up.
|
||||
</p>
|
||||
</CardContent>
|
||||
@ -359,30 +359,30 @@ export default function JoinPage() {
|
||||
if (!open) setConflictMessage(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-md">
|
||||
<DialogContent className="bg-popover border-border sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
|
||||
<AlertTriangle className="size-4 text-amber-400" />
|
||||
Can't join {peek.account_name} with this account
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{conflictMessage}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2 py-2 text-xs text-slate-500">
|
||||
<div className="space-y-2 py-2 text-xs text-muted-foreground">
|
||||
<p>
|
||||
To join{' '}
|
||||
<span className="text-slate-300">{peek.account_name}</span>,
|
||||
<span className="text-popover-foreground">{peek.account_name}</span>,
|
||||
sign out and sign up again with a different email address.
|
||||
The invite link stays valid as long as it hasn't
|
||||
expired.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<DialogFooter className="bg-popover border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConflictMessage(null)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-popover-foreground hover:bg-muted"
|
||||
>
|
||||
Stay signed in
|
||||
</Button>
|
||||
@ -409,7 +409,7 @@ export default function JoinPage() {
|
||||
|
||||
// ----- Not authed: prompt to sign up or sign in -----
|
||||
return (
|
||||
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||
<Card className="w-full max-w-md border-border bg-card">
|
||||
{inviteHeader}
|
||||
<CardContent className="flex flex-col gap-2">
|
||||
<Link href={`/signup?invite=${encodeURIComponent(token!)}`}>
|
||||
@ -420,7 +420,7 @@ export default function JoinPage() {
|
||||
<Link href={`/login?invite=${encodeURIComponent(token!)}`}>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
className="w-full border-border text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
I already have an account
|
||||
</Button>
|
||||
|
||||
@ -37,7 +37,7 @@ export const metadata: Metadata = {
|
||||
|
||||
export default function JoinLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1,10 +1,17 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/hooks/use-theme";
|
||||
import { DEFAULT_THEME, STORAGE_KEY, THEME_IDS } from "@/lib/themes";
|
||||
import { ThemedToaster } from "@/components/themed-toaster";
|
||||
import {
|
||||
DEFAULT_MODE,
|
||||
DEFAULT_THEME,
|
||||
MODE_STORAGE_KEY,
|
||||
MODES,
|
||||
STORAGE_KEY,
|
||||
THEME_IDS,
|
||||
} from "@/lib/themes";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
@ -33,29 +40,37 @@ export const metadata: Metadata = {
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: "#020617",
|
||||
colorScheme: "dark",
|
||||
colorScheme: "dark light",
|
||||
};
|
||||
|
||||
// Inline boot script — runs before React hydrates so the user's
|
||||
// chosen theme is on the <html> element before first paint. Without
|
||||
// this every page load flashes the default Violet for a frame before
|
||||
// the React tree mounts and applies the picked theme.
|
||||
// chosen accent (data-theme) AND mode (data-mode) are on the <html>
|
||||
// element before first paint. Without this every page load flashes
|
||||
// the server-rendered defaults for a frame before the React tree
|
||||
// mounts and applies the picked values.
|
||||
//
|
||||
// Kept dependency-free (no imports, no JSX) — must be a string the
|
||||
// browser can run as a single <script>. Knowledge of valid theme IDs
|
||||
// is sourced from the THEME_IDS constant so adding a theme doesn't
|
||||
// browser can run as a single <script>. Knowledge of valid ids is
|
||||
// sourced from the THEME_IDS / MODES constants so adding one doesn't
|
||||
// silently break the boot path.
|
||||
const THEME_BOOT_SCRIPT = `
|
||||
(function(){
|
||||
var d = document.documentElement;
|
||||
try {
|
||||
var STORAGE_KEY = ${JSON.stringify(STORAGE_KEY)};
|
||||
var DEFAULT = ${JSON.stringify(DEFAULT_THEME)};
|
||||
var ALLOWED = ${JSON.stringify(THEME_IDS)};
|
||||
var saved = localStorage.getItem(STORAGE_KEY);
|
||||
var theme = ALLOWED.indexOf(saved) !== -1 ? saved : DEFAULT;
|
||||
document.documentElement.dataset.theme = theme;
|
||||
var THEME_KEY = ${JSON.stringify(STORAGE_KEY)};
|
||||
var THEME_DEFAULT = ${JSON.stringify(DEFAULT_THEME)};
|
||||
var THEMES = ${JSON.stringify(THEME_IDS)};
|
||||
var savedTheme = localStorage.getItem(THEME_KEY);
|
||||
d.dataset.theme = THEMES.indexOf(savedTheme) !== -1 ? savedTheme : THEME_DEFAULT;
|
||||
|
||||
var MODE_KEY = ${JSON.stringify(MODE_STORAGE_KEY)};
|
||||
var MODE_DEFAULT = ${JSON.stringify(DEFAULT_MODE)};
|
||||
var MODES = ${JSON.stringify(MODES)};
|
||||
var savedMode = localStorage.getItem(MODE_KEY);
|
||||
d.dataset.mode = MODES.indexOf(savedMode) !== -1 ? savedMode : MODE_DEFAULT;
|
||||
} catch (_e) {
|
||||
document.documentElement.dataset.theme = ${JSON.stringify(DEFAULT_THEME)};
|
||||
d.dataset.theme = ${JSON.stringify(DEFAULT_THEME)};
|
||||
d.dataset.mode = ${JSON.stringify(DEFAULT_MODE)};
|
||||
}
|
||||
})();
|
||||
`;
|
||||
@ -69,13 +84,15 @@ export default function RootLayout({
|
||||
<html
|
||||
lang="en"
|
||||
data-theme={DEFAULT_THEME}
|
||||
data-mode={DEFAULT_MODE}
|
||||
className={`${inter.variable} h-full antialiased`}
|
||||
// The `theme-boot` script below rewrites `data-theme` on <html>
|
||||
// from localStorage before React hydrates, so for any non-default
|
||||
// theme the client DOM intentionally differs from the server-
|
||||
// rendered `DEFAULT_THEME`. suppressHydrationWarning silences the
|
||||
// expected mismatch — it only applies to this element's own
|
||||
// attributes, so genuine mismatches in children still surface.
|
||||
// The `theme-boot` script below rewrites `data-theme` and
|
||||
// `data-mode` on <html> from localStorage before React hydrates,
|
||||
// so for any non-default choice the client DOM intentionally
|
||||
// differs from the server-rendered defaults. suppressHydration-
|
||||
// Warning silences the expected mismatch — it only applies to
|
||||
// this element's own attributes, so genuine mismatches in
|
||||
// children still surface.
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
@ -88,17 +105,7 @@ export default function RootLayout({
|
||||
<body className="min-h-full bg-background text-foreground font-sans">
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
<Toaster
|
||||
theme="dark"
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "rgb(30 41 59)",
|
||||
border: "1px solid rgb(51 65 85)",
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<ThemedToaster />
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -95,7 +95,7 @@ const STEP_META: Record<AutomationStepType, StepMeta> = {
|
||||
assign_conversation: { label: "Assign Conversation", icon: UserCheck, border: "border-l-primary" },
|
||||
update_contact_field: { label: "Update Contact Field", icon: PencilLine, border: "border-l-primary" },
|
||||
create_deal: { label: "Create Deal", icon: Briefcase, border: "border-l-primary" },
|
||||
wait: { label: "Wait", icon: Hourglass, border: "border-l-slate-500" },
|
||||
wait: { label: "Wait", icon: Hourglass, border: "border-l-border" },
|
||||
condition: { label: "Condition (If/Else)", icon: GitBranch, border: "border-l-amber-500" },
|
||||
send_webhook: { label: "Send Webhook", icon: Webhook, border: "border-l-primary" },
|
||||
close_conversation: { label: "Close Conversation", icon: CircleSlash, border: "border-l-primary" },
|
||||
@ -251,7 +251,7 @@ function ResourcesProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
const SELECT_CLASS =
|
||||
"w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white focus:border-primary focus:outline-none"
|
||||
"w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
|
||||
/** Tag dropdown by name + color, storing the tag's id. Falls back to a
|
||||
* raw id input when no tags exist yet. */
|
||||
@ -269,7 +269,7 @@ function TagSelect({
|
||||
placeholder="Tag id"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -277,7 +277,7 @@ function TagSelect({
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="h-3 w-3 shrink-0 rounded-full border border-slate-600"
|
||||
className="h-3 w-3 shrink-0 rounded-full border border-border"
|
||||
style={{ backgroundColor: selected?.color ?? "transparent" }}
|
||||
aria-hidden
|
||||
/>
|
||||
@ -358,7 +358,7 @@ function AgentSelect({
|
||||
placeholder="Agent id"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -405,7 +405,7 @@ function SendTemplateFields({
|
||||
onChange={(e) =>
|
||||
onChange({ template_name: e.target.value, language })
|
||||
}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
<FieldBlock label="Language">
|
||||
@ -414,7 +414,7 @@ function SendTemplateFields({
|
||||
onChange={(e) =>
|
||||
onChange({ template_name: templateName, language: e.target.value })
|
||||
}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
</>
|
||||
@ -548,15 +548,15 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col bg-slate-950">
|
||||
<div className="fixed inset-0 flex flex-col bg-background">
|
||||
{/* Top bar. At sub-sm widths the "Active" label is hidden and the
|
||||
switch moves to the right of the save button, so the name input
|
||||
gets maximum width. */}
|
||||
<header className="flex flex-shrink-0 items-center gap-2 border-b border-slate-800 bg-slate-900/80 px-3 py-3 sm:gap-3 sm:px-4">
|
||||
<header className="flex flex-shrink-0 items-center gap-2 border-b border-border bg-card/80 px-3 py-3 sm:gap-3 sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/automations")}
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Back to automations"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
@ -565,9 +565,9 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
|
||||
value={state.name}
|
||||
onChange={(e) => patchTop("name", e.target.value)}
|
||||
placeholder="Untitled automation"
|
||||
className="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1 text-sm font-semibold text-white placeholder:text-slate-500 focus:bg-slate-800 focus:outline-none sm:text-base"
|
||||
className="min-w-0 flex-1 rounded-md bg-transparent px-2 py-1 text-sm font-semibold text-foreground placeholder:text-muted-foreground focus:bg-muted focus:outline-none sm:text-base"
|
||||
/>
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="hidden sm:inline">Active</span>
|
||||
<Switch
|
||||
checked={state.is_active}
|
||||
@ -587,7 +587,7 @@ export function AutomationBuilder({ initial }: { initial: BuilderInitial }) {
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="relative flex-1 overflow-y-auto">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle,#1e293b_1px,transparent_1px)] [background-size:20px_20px] pointer-events-none" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle,var(--border)_1px,transparent_1px)] [background-size:20px_20px] pointer-events-none" />
|
||||
<div className="relative mx-auto flex max-w-2xl flex-col items-center gap-0 px-4 py-10">
|
||||
<ResourcesProvider>
|
||||
<TriggerCard
|
||||
@ -633,7 +633,7 @@ function TriggerCard({
|
||||
// Card width: full on mobile, fixed 320px on sm+. The canvas wrapper
|
||||
// (max-w-2xl + px-4) keeps this tidy on tablet/desktop.
|
||||
<div className="z-10 w-full max-w-[320px] sm:w-80">
|
||||
<div className="rounded-lg border border-slate-800 border-l-4 border-l-blue-500 bg-slate-900 shadow-lg">
|
||||
<div className="rounded-lg border border-border border-l-4 border-l-blue-500 bg-card shadow-lg">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
@ -644,24 +644,24 @@ function TriggerCard({
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] uppercase tracking-wide text-blue-300">Trigger</div>
|
||||
<div className="truncate text-sm font-medium text-white">
|
||||
<div className="truncate text-sm font-medium text-foreground">
|
||||
{TRIGGER_OPTIONS.find((o) => o.value === type)?.label ?? type}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-slate-400 transition-transform", open && "rotate-180")}
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="space-y-3 border-t border-slate-800 px-4 py-3">
|
||||
<div className="space-y-3 border-t border-border px-4 py-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-400">
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
Trigger type
|
||||
</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => onTypeChange(e.target.value as AutomationTriggerType)}
|
||||
className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white focus:border-primary focus:outline-none"
|
||||
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
>
|
||||
{TRIGGER_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
@ -669,7 +669,7 @@ function TriggerCard({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-[11px] text-slate-500">
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{TRIGGER_OPTIONS.find((o) => o.value === type)?.hint}
|
||||
</p>
|
||||
</div>
|
||||
@ -681,7 +681,7 @@ function TriggerCard({
|
||||
)}
|
||||
{type === "tag_added" && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-400">
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
Tag
|
||||
</label>
|
||||
<TagSelect
|
||||
@ -697,7 +697,7 @@ function TriggerCard({
|
||||
onChange={(e) =>
|
||||
onConfigChange({ ...config, schedule: e.target.value })
|
||||
}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -723,6 +723,18 @@ function KeywordMatchConfig({
|
||||
// when the trigger type changes, so the seed stays in sync.
|
||||
const [draft, setDraft] = useState(keywords.join(", "))
|
||||
|
||||
// Persist the default the <select> displays. The dropdown falls back to
|
||||
// "contains" for display, but leaving it untouched would otherwise omit
|
||||
// match_type from the saved config — and activation validation then
|
||||
// rejected it (trigger.match_type). Seed once on mount; the component
|
||||
// remounts when the trigger type changes, matching the keywords draft.
|
||||
useEffect(() => {
|
||||
if (config?.match_type == null) {
|
||||
onChange({ ...config, match_type: "contains" })
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
function commit() {
|
||||
const parsed = draft
|
||||
.split(",")
|
||||
@ -735,7 +747,7 @@ function KeywordMatchConfig({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-400">
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
Keywords (comma-separated)
|
||||
</label>
|
||||
<Input
|
||||
@ -749,17 +761,17 @@ function KeywordMatchConfig({
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. pricing, demo request, talk to sales"
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-slate-400">
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||
Match type
|
||||
</label>
|
||||
<select
|
||||
value={config?.match_type ?? "contains"}
|
||||
onChange={(e) => onChange({ ...config, match_type: e.target.value as "exact" | "contains" })}
|
||||
className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white focus:outline-none"
|
||||
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground focus:outline-none"
|
||||
>
|
||||
<option value="contains">Contains</option>
|
||||
<option value="exact">Exact</option>
|
||||
@ -858,7 +870,7 @@ function StepRenderer({
|
||||
<div className={cn("z-10 flex flex-col", width)}>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-slate-800 border-l-4 bg-slate-900 shadow-lg",
|
||||
"rounded-lg border border-border border-l-4 bg-card shadow-lg",
|
||||
meta.border,
|
||||
)}
|
||||
>
|
||||
@ -867,28 +879,28 @@ function StepRenderer({
|
||||
onClick={() => props.setExpandedId(expanded ? null : step.cid)}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 flex-shrink-0 text-slate-600" aria-hidden />
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-slate-800 text-slate-300">
|
||||
<GripVertical className="h-4 w-4 flex-shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[11px] uppercase tracking-wide text-slate-400">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
{isCondition ? "Condition" : step.step_type === "wait" ? "Wait" : "Action"}
|
||||
</div>
|
||||
<div className="truncate text-sm font-medium text-white">{meta.label}</div>
|
||||
<div className="truncate text-[11px] text-slate-500">{previewFor(step)}</div>
|
||||
<div className="truncate text-sm font-medium text-foreground">{meta.label}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">{previewFor(step)}</div>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn("h-4 w-4 text-slate-400 transition-transform", expanded && "rotate-180")}
|
||||
className={cn("h-4 w-4 text-muted-foreground transition-transform", expanded && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-800 px-4 py-3">
|
||||
<div className="border-t border-border px-4 py-3">
|
||||
<StepEditor
|
||||
step={step}
|
||||
onChange={(next) => props.updateStep(path, () => next)}
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-between gap-2 border-t border-slate-800 pt-3">
|
||||
<div className="mt-3 flex items-center justify-between gap-2 border-t border-border pt-3">
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -927,9 +939,14 @@ function StepRenderer({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* A condition branches into Yes/No (rendered above by
|
||||
ConditionBranches), so it has no linear "continue" path — adding
|
||||
the trailing connector here would produce a spurious third output. */}
|
||||
{!isCondition && (
|
||||
<AddButton
|
||||
onPick={(t) => props.addStepAt(parentScope, index + 1, t)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -990,17 +1007,17 @@ function BranchColumn({
|
||||
function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {
|
||||
return (
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div className="h-4 w-[2px] bg-slate-700" aria-hidden />
|
||||
<div className="h-4 w-[2px] bg-border" aria-hidden />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-dashed border-slate-700 bg-slate-950 text-slate-400 transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary data-[popup-open]:border-primary data-[popup-open]:bg-primary/20 data-[popup-open]:text-primary"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full border-2 border-dashed border-border bg-background text-muted-foreground transition-colors hover:border-primary hover:bg-primary/10 hover:text-primary data-[popup-open]:border-primary data-[popup-open]:bg-primary/20 data-[popup-open]:text-primary"
|
||||
aria-label="Add step"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="max-h-80 min-w-56 overflow-y-auto border-slate-700 bg-slate-900"
|
||||
className="max-h-80 min-w-56 overflow-y-auto border-border bg-popover"
|
||||
>
|
||||
{ADDABLE_STEPS.map((t) => {
|
||||
const Icon = STEP_META[t].icon
|
||||
@ -1013,7 +1030,7 @@ function AddButton({ onPick }: { onPick: (t: AutomationStepType) => void }) {
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="h-4 w-[2px] bg-slate-700" aria-hidden />
|
||||
<div className="h-4 w-[2px] bg-border" aria-hidden />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1041,7 +1058,7 @@ function StepEditor({
|
||||
value={(cfg.text as string) ?? ""}
|
||||
onChange={(e) => set({ text: e.target.value })}
|
||||
placeholder="Hi! Thanks for reaching out…"
|
||||
className="min-h-24 bg-slate-800 text-white"
|
||||
className="min-h-24 bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
)
|
||||
@ -1070,7 +1087,7 @@ function StepEditor({
|
||||
<select
|
||||
value={(cfg.mode as string) ?? "round_robin"}
|
||||
onChange={(e) => set({ mode: e.target.value })}
|
||||
className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white"
|
||||
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value="round_robin">Round-robin</option>
|
||||
<option value="specific">Specific agent</option>
|
||||
@ -1100,7 +1117,7 @@ function StepEditor({
|
||||
value={(cfg.value as string) ?? ""}
|
||||
onChange={(e) => set({ value: e.target.value })}
|
||||
placeholder="Text or {{ vars.x }} / {{ message.text }}"
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
</>
|
||||
@ -1112,21 +1129,21 @@ function StepEditor({
|
||||
<Input
|
||||
value={(cfg.pipeline_id as string) ?? ""}
|
||||
onChange={(e) => set({ pipeline_id: e.target.value })}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
<FieldBlock label="Stage id">
|
||||
<Input
|
||||
value={(cfg.stage_id as string) ?? ""}
|
||||
onChange={(e) => set({ stage_id: e.target.value })}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
<FieldBlock label="Title">
|
||||
<Input
|
||||
value={(cfg.title as string) ?? ""}
|
||||
onChange={(e) => set({ title: e.target.value })}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
<FieldBlock label="Value">
|
||||
@ -1134,7 +1151,7 @@ function StepEditor({
|
||||
type="number"
|
||||
value={(cfg.value as number) ?? 0}
|
||||
onChange={(e) => set({ value: Number(e.target.value) })}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
</>
|
||||
@ -1148,14 +1165,14 @@ function StepEditor({
|
||||
min={1}
|
||||
value={(cfg.amount as number) ?? 1}
|
||||
onChange={(e) => set({ amount: Math.max(1, Number(e.target.value)) })}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
<FieldBlock label="Unit">
|
||||
<select
|
||||
value={(cfg.unit as string) ?? "hours"}
|
||||
onChange={(e) => set({ unit: e.target.value })}
|
||||
className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white"
|
||||
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value="minutes">Minutes</option>
|
||||
<option value="hours">Hours</option>
|
||||
@ -1171,7 +1188,7 @@ function StepEditor({
|
||||
<select
|
||||
value={(cfg.subject as string) ?? "tag_presence"}
|
||||
onChange={(e) => set({ subject: e.target.value })}
|
||||
className="w-full rounded-md border border-slate-700 bg-slate-800 px-2 py-1.5 text-sm text-white"
|
||||
className="w-full rounded-md border border-border bg-muted px-2 py-1.5 text-sm text-foreground"
|
||||
>
|
||||
<option value="tag_presence">Tag presence</option>
|
||||
<option value="contact_field">Contact field</option>
|
||||
@ -1192,7 +1209,7 @@ function StepEditor({
|
||||
}
|
||||
value={(cfg.operand as string) ?? ""}
|
||||
onChange={(e) => set({ operand: e.target.value })}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
{(cfg.subject === "contact_field" || cfg.subject === "message_content") && (
|
||||
@ -1200,7 +1217,7 @@ function StepEditor({
|
||||
<Input
|
||||
value={(cfg.value as string) ?? ""}
|
||||
onChange={(e) => set({ value: e.target.value })}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
)}
|
||||
@ -1213,21 +1230,21 @@ function StepEditor({
|
||||
<Input
|
||||
value={(cfg.url as string) ?? ""}
|
||||
onChange={(e) => set({ url: e.target.value })}
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
<FieldBlock label="Body template (JSON)">
|
||||
<Textarea
|
||||
value={(cfg.body_template as string) ?? ""}
|
||||
onChange={(e) => set({ body_template: e.target.value })}
|
||||
className="min-h-20 bg-slate-800 font-mono text-xs text-white"
|
||||
className="min-h-20 bg-muted font-mono text-xs text-foreground"
|
||||
/>
|
||||
</FieldBlock>
|
||||
</>
|
||||
)
|
||||
case "close_conversation":
|
||||
return (
|
||||
<p className="text-xs text-slate-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sets the conversation status to "closed". No configuration needed.
|
||||
</p>
|
||||
)
|
||||
@ -1245,7 +1262,7 @@ function FieldBlock({
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-2 last:mb-0">
|
||||
<label className="mb-1 block text-xs font-medium text-slate-400">{label}</label>
|
||||
<label className="mb-1 block text-xs font-medium text-muted-foreground">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -68,17 +68,17 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Choose a Template</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<h2 className="text-lg font-semibold text-foreground">Choose a Template</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Select an approved message template for your broadcast.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/50">
|
||||
<FileText className="mb-2 h-8 w-8 text-slate-600" />
|
||||
<p className="text-sm text-slate-400">No templates available.</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Create a template in Settings first.</p>
|
||||
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-border bg-card/50">
|
||||
<FileText className="mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">No templates available.</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">Create a template in Settings first.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
@ -93,19 +93,19 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
|
||||
className={`flex flex-col gap-3 rounded-xl border p-4 text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
|
||||
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700 hover:bg-slate-900'
|
||||
: 'border-border bg-card/50 hover:border-border hover:bg-card'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-sm font-medium text-white">{template.name}</h3>
|
||||
<h3 className="text-sm font-medium text-foreground">{template.name}</h3>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${catColor}`}
|
||||
>
|
||||
{template.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="line-clamp-3 text-xs text-slate-400">{template.body_text}</p>
|
||||
<div className="flex items-center gap-2 text-[10px] text-slate-500">
|
||||
<p className="line-clamp-3 text-xs text-muted-foreground">{template.body_text}</p>
|
||||
<div className="flex items-center gap-2 text-[10px] text-muted-foreground">
|
||||
<span>{template.language ?? 'en_US'}</span>
|
||||
{/* Status is omitted on purpose — every template
|
||||
shown here is already filtered to APPROVED,
|
||||
@ -117,8 +117,8 @@ export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||
<Button variant="outline" onClick={onBack} className="border-slate-700 text-slate-300">
|
||||
<div className="flex items-center justify-between border-t border-border pt-4">
|
||||
<Button variant="outline" onClick={onBack} className="border-border text-muted-foreground">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@ -249,8 +249,8 @@ export function Step2SelectAudience({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Select Audience</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<h2 className="text-lg font-semibold text-foreground">Select Audience</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Choose who will receive this broadcast.
|
||||
</p>
|
||||
</div>
|
||||
@ -280,21 +280,21 @@ export function Step2SelectAudience({
|
||||
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/30'
|
||||
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700'
|
||||
: 'border-border bg-card/50 hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'bg-slate-800 text-slate-400'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">{option.label}</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">
|
||||
<p className="text-sm font-medium text-foreground">{option.label}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{option.description}
|
||||
</p>
|
||||
</div>
|
||||
@ -304,12 +304,12 @@ export function Step2SelectAudience({
|
||||
</div>
|
||||
|
||||
{audience.type === 'tags' && (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<p className="mb-3 text-sm font-medium text-white">Select Tags</p>
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="mb-3 text-sm font-medium text-foreground">Select Tags</p>
|
||||
{loadingTags ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
) : tags.length === 0 ? (
|
||||
<p className="text-xs text-slate-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No tags found. Create tags in Settings.
|
||||
</p>
|
||||
) : (
|
||||
@ -323,7 +323,7 @@ export function Step2SelectAudience({
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
|
||||
isSelected
|
||||
? 'border-primary/30 bg-primary/10 text-primary'
|
||||
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600'
|
||||
: 'border-border bg-muted text-muted-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@ -340,12 +340,12 @@ export function Step2SelectAudience({
|
||||
)}
|
||||
|
||||
{audience.type === 'custom_field' && (
|
||||
<div className="space-y-3 rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<p className="text-sm font-medium text-white">Custom Field Filter</p>
|
||||
<div className="space-y-3 rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="text-sm font-medium text-foreground">Custom Field Filter</p>
|
||||
{loadingFields ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
) : customFields.length === 0 ? (
|
||||
<p className="text-xs text-slate-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No custom fields defined. Create one in Settings → Custom Fields.
|
||||
</p>
|
||||
) : (
|
||||
@ -353,7 +353,7 @@ export function Step2SelectAudience({
|
||||
<select
|
||||
value={audience.customField?.fieldId ?? ''}
|
||||
onChange={(e) => updateCustomField({ fieldId: e.target.value })}
|
||||
className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">Select field…</option>
|
||||
{customFields.map((f) => (
|
||||
@ -369,7 +369,7 @@ export function Step2SelectAudience({
|
||||
operator: e.target.value as CustomFieldOperator,
|
||||
})
|
||||
}
|
||||
className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
{OPERATOR_OPTIONS.map((op) => (
|
||||
<option key={op.value} value={op.value}>
|
||||
@ -382,7 +382,7 @@ export function Step2SelectAudience({
|
||||
value={audience.customField?.value ?? ''}
|
||||
onChange={(e) => updateCustomField({ value: e.target.value })}
|
||||
placeholder="Value"
|
||||
className="h-9 rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none placeholder:text-slate-500 focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
className="h-9 rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none placeholder:text-muted-foreground focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -390,16 +390,16 @@ export function Step2SelectAudience({
|
||||
)}
|
||||
|
||||
{/* Exclude list — applies regardless of audience type */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<X className="h-4 w-4 text-red-400" />
|
||||
<p className="text-sm font-medium text-white">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
Exclude contacts with these tags
|
||||
</p>
|
||||
<span className="text-xs text-slate-500">(optional)</span>
|
||||
<span className="text-xs text-muted-foreground">(optional)</span>
|
||||
</div>
|
||||
{tags.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">No tags available.</p>
|
||||
<p className="text-xs text-muted-foreground">No tags available.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => {
|
||||
@ -411,7 +411,7 @@ export function Step2SelectAudience({
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
|
||||
isExcluded
|
||||
? 'border-red-500/30 bg-red-500/10 text-red-300'
|
||||
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600'
|
||||
: 'border-border bg-muted text-muted-foreground hover:border-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
@ -427,33 +427,33 @@ export function Step2SelectAudience({
|
||||
</div>
|
||||
|
||||
{/* Audience Summary */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-white">Audience Summary</p>
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<p className="mb-2 text-sm font-medium text-foreground">Audience Summary</p>
|
||||
{loadingCount ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<span className="text-xs text-slate-400">Calculating…</span>
|
||||
<span className="text-xs text-muted-foreground">Calculating…</span>
|
||||
</div>
|
||||
) : estimatedCount !== null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm text-white">
|
||||
<span className="text-sm text-foreground">
|
||||
{estimatedCount.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">estimated recipients</span>
|
||||
<span className="text-xs text-muted-foreground">estimated recipients</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select an audience type to see the estimate.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||
<div className="flex items-center justify-between border-t border-border pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="border-slate-700 text-slate-300"
|
||||
className="border-border text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ArrowLeft, ArrowRight, Eye, Loader2 } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowRight, Eye, ImageIcon, Loader2 } from 'lucide-react';
|
||||
|
||||
type VariableType = 'static' | 'field' | 'custom_field';
|
||||
|
||||
@ -25,10 +25,29 @@ interface Step3Props {
|
||||
template: MessageTemplate;
|
||||
variables: Record<string, VariableMapping>;
|
||||
onUpdate: (variables: Record<string, VariableMapping>) => void;
|
||||
/** Media URL for an IMAGE/VIDEO/DOCUMENT header, when the template has one. */
|
||||
headerMediaUrl: string;
|
||||
onHeaderMediaUrlChange: (url: string) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const MEDIA_HEADER_TYPES = ['image', 'video', 'document'] as const;
|
||||
type MediaHeaderType = (typeof MEDIA_HEADER_TYPES)[number];
|
||||
|
||||
function isMediaHeaderType(value: unknown): value is MediaHeaderType {
|
||||
return MEDIA_HEADER_TYPES.includes(value as MediaHeaderType);
|
||||
}
|
||||
|
||||
function isValidHttpUrl(value: string): boolean {
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'http:' || u.protocol === 'https:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const contactFields = [
|
||||
{ value: 'name', label: 'Contact Name' },
|
||||
{ value: 'phone', label: 'Phone Number' },
|
||||
@ -52,6 +71,8 @@ export function Step3Personalize({
|
||||
template,
|
||||
variables,
|
||||
onUpdate,
|
||||
headerMediaUrl,
|
||||
onHeaderMediaUrlChange,
|
||||
onNext,
|
||||
onBack,
|
||||
}: Step3Props) {
|
||||
@ -112,6 +133,33 @@ export function Step3Personalize({
|
||||
return [...new Set(matches)].sort();
|
||||
}, [template.body_text]);
|
||||
|
||||
// Templates with an IMAGE/VIDEO/DOCUMENT header need a media URL at
|
||||
// send time — Meta requires the media component on every delivery and
|
||||
// rejects the broadcast without it. The field is hidden for text-only
|
||||
// headers.
|
||||
const mediaHeaderType = isMediaHeaderType(template.header_type)
|
||||
? template.header_type
|
||||
: null;
|
||||
|
||||
// Seed the field with the template's stored sample URL the first time
|
||||
// we land on a media-header template, so the common "reuse the
|
||||
// approved media" case needs no typing. Only seeds when empty to avoid
|
||||
// clobbering a URL the user already edited.
|
||||
useEffect(() => {
|
||||
if (mediaHeaderType && !headerMediaUrl && template.header_media_url) {
|
||||
onHeaderMediaUrlChange(template.header_media_url);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mediaHeaderType, template.header_media_url]);
|
||||
|
||||
const headerMediaError = useMemo<'missing' | 'invalid' | null>(() => {
|
||||
if (!mediaHeaderType) return null;
|
||||
const value = headerMediaUrl.trim();
|
||||
if (!value) return 'missing';
|
||||
if (!isValidHttpUrl(value)) return 'invalid';
|
||||
return null;
|
||||
}, [mediaHeaderType, headerMediaUrl]);
|
||||
|
||||
/**
|
||||
* A placeholder is "unmapped" if the user hasn't picked either a
|
||||
* static value or a field/custom-field source. Blocks Next until
|
||||
@ -187,20 +235,69 @@ export function Step3Personalize({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Personalize Message</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<h2 className="text-lg font-semibold text-foreground">Personalize Message</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Map template variables to contact fields, custom fields, or static
|
||||
values.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{placeholders.length === 0 ? (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-6 text-center">
|
||||
<p className="text-sm text-slate-400">
|
||||
{mediaHeaderType && (
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<ImageIcon className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium text-foreground">Header media</p>
|
||||
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-medium uppercase text-primary">
|
||||
{mediaHeaderType}
|
||||
</span>
|
||||
</div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||
Media URL
|
||||
</label>
|
||||
<Input
|
||||
type="url"
|
||||
value={headerMediaUrl}
|
||||
onChange={(e) => onHeaderMediaUrlChange(e.target.value)}
|
||||
placeholder={`https://example.com/header.${
|
||||
mediaHeaderType === 'image'
|
||||
? 'jpg'
|
||||
: mediaHeaderType === 'video'
|
||||
? 'mp4'
|
||||
: 'pdf'
|
||||
}`}
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Public URL of the {mediaHeaderType} sent as the message header.
|
||||
Used for every recipient in this broadcast.
|
||||
</p>
|
||||
{mediaHeaderType === 'image' &&
|
||||
headerMediaError === null &&
|
||||
headerMediaUrl.trim() && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={headerMediaUrl.trim()}
|
||||
alt="Header preview"
|
||||
className="mt-3 max-h-40 rounded-lg border border-border object-contain"
|
||||
/>
|
||||
)}
|
||||
{headerMediaError && (
|
||||
<p className="mt-1.5 text-xs text-amber-300">
|
||||
{headerMediaError === 'missing'
|
||||
? 'A media URL is required to send this template.'
|
||||
: 'Enter a valid http(s) URL.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{placeholders.length === 0 && !mediaHeaderType ? (
|
||||
<div className="rounded-xl border border-border bg-card/50 p-6 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This template has no variables to personalize.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
) : placeholders.length === 0 ? null : (
|
||||
<div className="space-y-4">
|
||||
{placeholders.map((placeholder) => {
|
||||
const key = placeholder.replace(/^\{\{|\}\}$/g, '');
|
||||
@ -209,7 +306,7 @@ export function Step3Personalize({
|
||||
return (
|
||||
<div
|
||||
key={placeholder}
|
||||
className="rounded-xl border border-slate-800 bg-slate-900/50 p-4"
|
||||
className="rounded-xl border border-border bg-card/50 p-4"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-mono font-medium text-primary">
|
||||
@ -219,7 +316,7 @@ export function Step3Personalize({
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-slate-400">
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||
Mapping Type
|
||||
</label>
|
||||
<Select
|
||||
@ -231,10 +328,10 @@ export function Step3Personalize({
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
|
||||
<SelectTrigger className="w-full border-border bg-muted text-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-700 bg-slate-800">
|
||||
<SelectContent className="border-border bg-popover">
|
||||
<SelectItem value="static">Static Value</SelectItem>
|
||||
<SelectItem value="field">Contact Field</SelectItem>
|
||||
<SelectItem value="custom_field">
|
||||
@ -245,7 +342,7 @@ export function Step3Personalize({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-slate-400">
|
||||
<label className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||
{mapping.type === 'static' ? 'Value' : 'Field'}
|
||||
</label>
|
||||
{mapping.type === 'static' ? (
|
||||
@ -255,7 +352,7 @@ export function Step3Personalize({
|
||||
updateVariable(key, { value: e.target.value })
|
||||
}
|
||||
placeholder="Enter value..."
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
) : mapping.type === 'field' ? (
|
||||
<Select
|
||||
@ -264,10 +361,10 @@ export function Step3Personalize({
|
||||
updateVariable(key, { value: val || '' })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
|
||||
<SelectTrigger className="w-full border-border bg-muted text-foreground">
|
||||
<SelectValue placeholder="Select field..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-700 bg-slate-800">
|
||||
<SelectContent className="border-border bg-popover">
|
||||
{contactFields.map((field) => (
|
||||
<SelectItem key={field.value} value={field.value}>
|
||||
{field.label}
|
||||
@ -282,7 +379,7 @@ export function Step3Personalize({
|
||||
updateVariable(key, { value: val || '' })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
|
||||
<SelectTrigger className="w-full border-border bg-muted text-foreground">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingFields
|
||||
@ -293,7 +390,7 @@ export function Step3Personalize({
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-700 bg-slate-800">
|
||||
<SelectContent className="border-border bg-popover">
|
||||
{customFields.map((f) => (
|
||||
<SelectItem key={f.id} value={f.id}>
|
||||
{f.field_name}
|
||||
@ -312,11 +409,11 @@ export function Step3Personalize({
|
||||
|
||||
{/* Live Preview — rendered as a WhatsApp-style bubble so the user
|
||||
sees approximately what the recipient will see. */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-primary" />
|
||||
<p className="text-sm font-medium text-white">Live Preview</p>
|
||||
<span className="text-xs text-slate-500">({previewLabel})</span>
|
||||
<p className="text-sm font-medium text-foreground">Live Preview</p>
|
||||
<span className="text-xs text-muted-foreground">({previewLabel})</span>
|
||||
{loadingPreview && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||
)}
|
||||
@ -340,18 +437,18 @@ export function Step3Personalize({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||
<div className="flex items-center justify-between border-t border-border pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
className="border-slate-700 text-slate-300"
|
||||
className="border-border text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={unmappedKeys.length > 0}
|
||||
disabled={unmappedKeys.length > 0 || headerMediaError !== null}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
|
||||
@ -93,51 +93,51 @@ export function Step4ScheduleSend({
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Review & Send</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
<h2 className="text-lg font-semibold text-foreground">Review & Send</h2>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Name your broadcast, review the details, and send.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Broadcast Name */}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-white">Broadcast Name</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-foreground">Broadcast Name</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
placeholder="e.g. Summer Sale Announcement"
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Summary Card */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-white">Summary</p>
|
||||
<div className="rounded-xl border border-border bg-card/50 p-4 space-y-3">
|
||||
<p className="text-sm font-medium text-foreground">Summary</p>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Template</p>
|
||||
<p className="text-white">{template.name}</p>
|
||||
<p className="text-xs text-muted-foreground">Template</p>
|
||||
<p className="text-foreground">{template.name}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Audience</p>
|
||||
<p className="text-white">{audienceLabel}</p>
|
||||
<p className="text-xs text-muted-foreground">Audience</p>
|
||||
<p className="text-foreground">{audienceLabel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Estimated Reach</p>
|
||||
<p className="text-xs text-muted-foreground">Estimated Reach</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{loadingReach ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-primary" />
|
||||
) : (
|
||||
<>
|
||||
<Users className="h-3.5 w-3.5 text-primary" />
|
||||
<p className="font-medium text-white">{estimatedReach.toLocaleString()}</p>
|
||||
<p className="font-medium text-foreground">{estimatedReach.toLocaleString()}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Language</p>
|
||||
<p className="text-white">{template.language ?? 'en_US'}</p>
|
||||
<p className="text-xs text-muted-foreground">Language</p>
|
||||
<p className="text-foreground">{template.language ?? 'en_US'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -148,11 +148,11 @@ export function Step4ScheduleSend({
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||
<p className="text-sm font-medium text-white">Sending broadcast...</p>
|
||||
<p className="text-sm font-medium text-foreground">Sending broadcast...</p>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-primary">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-800">
|
||||
<div className="h-1.5 w-full rounded-full bg-muted">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
@ -161,12 +161,12 @@ export function Step4ScheduleSend({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-slate-800 pt-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-border pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
disabled={isProcessing}
|
||||
className="border-slate-700 text-slate-300"
|
||||
className="border-border text-muted-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
@ -178,7 +178,7 @@ export function Step4ScheduleSend({
|
||||
variant="outline"
|
||||
onClick={onSaveDraft}
|
||||
disabled={!name.trim() || isProcessing}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800 disabled:opacity-50"
|
||||
className="border-border text-muted-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
Save as Draft
|
||||
@ -197,14 +197,14 @@ export function Step4ScheduleSend({
|
||||
<Send className="h-4 w-4" />
|
||||
Send Broadcast
|
||||
</DialogTrigger>
|
||||
<DialogContent className="border-slate-700 bg-slate-900 sm:max-w-md">
|
||||
<DialogContent className="border-border bg-popover sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Confirm Broadcast</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogTitle className="text-popover-foreground">Confirm Broadcast</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
You are about to send this broadcast to{' '}
|
||||
<span className="font-medium text-white">{estimatedReach.toLocaleString()}</span>{' '}
|
||||
<span className="font-medium text-popover-foreground">{estimatedReach.toLocaleString()}</span>{' '}
|
||||
contacts using the{' '}
|
||||
<span className="font-medium text-white">{template.name}</span> template.
|
||||
<span className="font-medium text-popover-foreground">{template.name}</span> template.
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@ -212,7 +212,7 @@ export function Step4ScheduleSend({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className="border-slate-700 text-slate-300"
|
||||
className="border-border text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@ -5,7 +5,11 @@ import { createClient } from '@/lib/supabase/client';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { formatCurrency } from '@/lib/currency';
|
||||
import { toast } from 'sonner';
|
||||
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal } from '@/types';
|
||||
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue, Deal, MessageTemplate } from '@/types';
|
||||
import {
|
||||
TemplatePicker,
|
||||
type TemplateSendValues,
|
||||
} from '@/components/inbox/template-picker';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@ -33,6 +37,7 @@ import {
|
||||
Save,
|
||||
X,
|
||||
DollarSign,
|
||||
LayoutTemplate,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ContactDetailViewProps {
|
||||
@ -55,6 +60,12 @@ export function ContactDetailView({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copiedPhone, setCopiedPhone] = useState(false);
|
||||
|
||||
// Send template — lets the business initiate (or re-open) a conversation
|
||||
// with this contact by sending an approved template. The send route
|
||||
// find-or-creates the conversation, so no inbound message is required.
|
||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
||||
const [sendingTemplate, setSendingTemplate] = useState(false);
|
||||
|
||||
// Details tab
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editPhone, setEditPhone] = useState('');
|
||||
@ -317,6 +328,48 @@ export function ContactDetailView({
|
||||
setSavingCustom(false);
|
||||
}
|
||||
|
||||
async function handleSendTemplate(
|
||||
template: MessageTemplate,
|
||||
values: TemplateSendValues,
|
||||
) {
|
||||
if (!contactId) return;
|
||||
setSendingTemplate(true);
|
||||
try {
|
||||
const res = await fetch('/api/whatsapp/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
// No conversation_id — the route find-or-creates one for this
|
||||
// contact, mirroring the inbox template-send payload otherwise.
|
||||
contact_id: contactId,
|
||||
message_type: 'template',
|
||||
template_name: template.name,
|
||||
template_language: template.language,
|
||||
template_message_params: {
|
||||
body: values.body,
|
||||
headerText: values.headerText,
|
||||
buttonParams: values.buttonParams,
|
||||
},
|
||||
template_params: values.body,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const reason = payload?.error || `HTTP ${res.status}`;
|
||||
toast.error(`Failed to send template: ${reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(`Template "${template.name}" sent`);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : 'network error';
|
||||
toast.error(`Failed to send template: ${reason}`);
|
||||
} finally {
|
||||
setSendingTemplate(false);
|
||||
}
|
||||
}
|
||||
|
||||
function getInitials(name?: string | null) {
|
||||
if (!name) return '?';
|
||||
return name
|
||||
@ -328,10 +381,11 @@ export function ContactDetailView({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg w-full p-0"
|
||||
className="bg-popover border-border text-popover-foreground sm:max-w-lg w-full p-0"
|
||||
>
|
||||
{loading || !contact ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
@ -340,21 +394,21 @@ export function ContactDetailView({
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<SheetHeader className="p-4 border-b border-slate-700/50">
|
||||
<SheetHeader className="p-4 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-12 bg-slate-800 border border-slate-700">
|
||||
<Avatar className="size-12 bg-muted border border-border">
|
||||
<AvatarFallback className="bg-primary/10 text-primary text-sm font-medium">
|
||||
{getInitials(contact.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<SheetTitle className="text-white truncate">
|
||||
<SheetTitle className="text-popover-foreground truncate">
|
||||
{contact.name || 'Unknown'}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-slate-400 text-xs mt-0.5">
|
||||
<SheetDescription className="text-muted-foreground text-xs mt-0.5">
|
||||
Contact details
|
||||
</SheetDescription>
|
||||
<div className="flex flex-wrap items-center gap-3 mt-1.5 text-xs text-slate-400">
|
||||
<div className="flex flex-wrap items-center gap-3 mt-1.5 text-xs text-muted-foreground">
|
||||
<button
|
||||
onClick={copyPhone}
|
||||
className="flex items-center gap-1 hover:text-primary transition-colors cursor-pointer"
|
||||
@ -382,38 +436,53 @@ export function ContactDetailView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setTemplatePickerOpen(true)}
|
||||
disabled={sendingTemplate}
|
||||
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{sendingTemplate ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<LayoutTemplate className="size-4" />
|
||||
)}
|
||||
Send template
|
||||
</Button>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="details" className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList className="bg-slate-800/50 border-b border-slate-700 mx-4 mt-3">
|
||||
<TabsList className="bg-muted/50 border-b border-border mx-4 mt-3">
|
||||
<TabsTrigger
|
||||
value="details"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
|
||||
>
|
||||
Details
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="tags"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
|
||||
>
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="notes"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
|
||||
>
|
||||
Notes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="custom"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
|
||||
>
|
||||
Custom Fields
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="deals"
|
||||
className="data-active:bg-slate-800 data-active:text-primary text-slate-400"
|
||||
className="data-active:bg-muted data-active:text-primary text-muted-foreground"
|
||||
>
|
||||
Deals
|
||||
</TabsTrigger>
|
||||
@ -423,37 +492,37 @@ export function ContactDetailView({
|
||||
<TabsContent value="details" className="flex-1 overflow-y-auto px-4 py-3">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-400 text-xs">Name</Label>
|
||||
<Label className="text-muted-foreground text-xs">Name</Label>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
|
||||
className="bg-muted border-border text-foreground h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-400 text-xs">
|
||||
<Label className="text-muted-foreground text-xs">
|
||||
Phone <span className="text-red-400">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={editPhone}
|
||||
onChange={(e) => setEditPhone(e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
|
||||
className="bg-muted border-border text-foreground h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-400 text-xs">Email</Label>
|
||||
<Label className="text-muted-foreground text-xs">Email</Label>
|
||||
<Input
|
||||
value={editEmail}
|
||||
onChange={(e) => setEditEmail(e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
|
||||
className="bg-muted border-border text-foreground h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-400 text-xs">Company</Label>
|
||||
<Label className="text-muted-foreground text-xs">Company</Label>
|
||||
<Input
|
||||
value={editCompany}
|
||||
onChange={(e) => setEditCompany(e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
|
||||
className="bg-muted border-border text-foreground h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
@ -475,11 +544,11 @@ export function ContactDetailView({
|
||||
{/* Tags Tab */}
|
||||
<TabsContent value="tags" className="flex-1 overflow-y-auto px-4 py-3">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-slate-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click a tag to add or remove it from this contact.
|
||||
</p>
|
||||
{allTags.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No tags available. Create tags in Settings.
|
||||
</p>
|
||||
) : (
|
||||
@ -493,7 +562,7 @@ export function ContactDetailView({
|
||||
disabled={savingTags}
|
||||
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium transition-all cursor-pointer ${
|
||||
selected
|
||||
? 'ring-2 ring-primary ring-offset-1 ring-offset-slate-900'
|
||||
? 'ring-2 ring-primary ring-offset-1 ring-offset-border'
|
||||
: 'opacity-50 hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
@ -518,7 +587,7 @@ export function ContactDetailView({
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
placeholder="Write a note..."
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 min-h-[60px] text-sm resize-none"
|
||||
className="bg-muted border-border text-foreground placeholder:text-muted-foreground min-h-[60px] text-sm resize-none"
|
||||
/>
|
||||
<Button
|
||||
onClick={addNote}
|
||||
@ -538,30 +607,30 @@ export function ContactDetailView({
|
||||
<div className="flex-1 overflow-y-auto space-y-2">
|
||||
{loadingNotes ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-5 animate-spin text-slate-500" />
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 text-center py-8">
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No notes yet.
|
||||
</p>
|
||||
) : (
|
||||
notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="rounded-lg bg-slate-800/50 border border-slate-700/50 p-3 group"
|
||||
className="rounded-lg bg-muted/50 border border-border/50 p-3 group"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm text-slate-300 whitespace-pre-wrap flex-1">
|
||||
<p className="text-sm text-muted-foreground whitespace-pre-wrap flex-1">
|
||||
{note.note_text}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => deleteNote(note.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all cursor-pointer shrink-0"
|
||||
className="opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-red-400 transition-all cursor-pointer shrink-0"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1.5">
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{new Date(note.created_at).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
@ -580,17 +649,17 @@ export function ContactDetailView({
|
||||
<TabsContent value="custom" className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{loadingCustom ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="size-5 animate-spin text-slate-500" />
|
||||
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : customFields.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 text-center py-8">
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No custom fields defined. Create them in Settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{customFields.map((field) => (
|
||||
<div key={field.id} className="space-y-1.5">
|
||||
<Label className="text-slate-400 text-xs capitalize">
|
||||
<Label className="text-muted-foreground text-xs capitalize">
|
||||
{field.field_name}
|
||||
</Label>
|
||||
<Input
|
||||
@ -602,7 +671,7 @@ export function ContactDetailView({
|
||||
}))
|
||||
}
|
||||
placeholder={`Enter ${field.field_name}...`}
|
||||
className="bg-slate-800 border-slate-700 text-white h-8 text-sm placeholder:text-slate-500"
|
||||
className="bg-muted border-border text-foreground h-8 text-sm placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -630,16 +699,16 @@ export function ContactDetailView({
|
||||
<Loader2 className="size-5 animate-spin text-primary" />
|
||||
</div>
|
||||
) : deals.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">No deals yet</p>
|
||||
<p className="text-xs text-muted-foreground">No deals yet</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{deals.map((deal) => (
|
||||
<div
|
||||
key={deal.id}
|
||||
className="rounded-lg border border-slate-700 bg-slate-800/50 p-3"
|
||||
className="rounded-lg border border-border bg-muted/50 p-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-white">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{deal.title}
|
||||
</p>
|
||||
{deal.stage && (
|
||||
@ -654,7 +723,7 @@ export function ContactDetailView({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-between text-xs text-slate-400">
|
||||
<div className="mt-1.5 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<DollarSign className="size-3" />
|
||||
{formatCurrency(
|
||||
@ -684,5 +753,11 @@ export function ContactDetailView({
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<TemplatePicker
|
||||
open={templatePickerOpen}
|
||||
onOpenChange={setTemplatePickerOpen}
|
||||
onSelect={handleSendTemplate}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -223,12 +223,12 @@ export function ContactForm({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-md">
|
||||
<DialogContent className="bg-popover border-border text-popover-foreground sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
<DialogTitle className="text-popover-foreground">
|
||||
{isEdit ? 'Edit Contact' : 'Add Contact'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{isEdit
|
||||
? 'Update the contact details below.'
|
||||
: 'Fill in the details to create a new contact.'}
|
||||
@ -237,7 +237,7 @@ export function ContactForm({
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cf-name" className="text-slate-300">
|
||||
<Label htmlFor="cf-name" className="text-muted-foreground">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
@ -245,12 +245,12 @@ export function ContactForm({
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cf-phone" className="text-slate-300">
|
||||
<Label htmlFor="cf-phone" className="text-muted-foreground">
|
||||
Phone <span className="text-red-400">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@ -262,7 +262,7 @@ export function ContactForm({
|
||||
}}
|
||||
onBlur={checkDuplicate}
|
||||
placeholder="+1 234 567 8900"
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
{dupMatch ? (
|
||||
<div
|
||||
@ -291,14 +291,14 @@ export function ContactForm({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include country code, e.g. +1 for US
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cf-email" className="text-slate-300">
|
||||
<Label htmlFor="cf-email" className="text-muted-foreground">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
@ -307,12 +307,12 @@ export function ContactForm({
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="john@example.com"
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cf-company" className="text-slate-300">
|
||||
<Label htmlFor="cf-company" className="text-muted-foreground">
|
||||
Company
|
||||
</Label>
|
||||
<Input
|
||||
@ -320,19 +320,19 @@ export function ContactForm({
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
placeholder="Acme Inc."
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Tags</Label>
|
||||
<Label className="text-muted-foreground">Tags</Label>
|
||||
{loadingTags ? (
|
||||
<div className="flex items-center gap-2 text-slate-500 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading tags...
|
||||
</div>
|
||||
) : tags.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No tags available. Create tags in Settings.
|
||||
</p>
|
||||
) : (
|
||||
@ -346,7 +346,7 @@ export function ContactForm({
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors cursor-pointer ${
|
||||
selected
|
||||
? 'ring-2 ring-primary ring-offset-1 ring-offset-slate-900'
|
||||
? 'ring-2 ring-primary ring-offset-1 ring-offset-border'
|
||||
: 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
@ -363,12 +363,12 @@ export function ContactForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<DialogFooter className="bg-popover border-border">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@ -33,10 +33,10 @@ export function CustomFieldsManager({
|
||||
}: CustomFieldsManagerProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="border-slate-700 bg-slate-900 text-slate-200 sm:max-w-md">
|
||||
<DialogContent className="border-border bg-popover text-popover-foreground sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Custom fields</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogTitle className="text-popover-foreground">Custom fields</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Define extra contact fields (e.g. ZIP code, lead source). They
|
||||
appear on every contact and in the “Update Contact Field” automation
|
||||
action.
|
||||
@ -185,7 +185,7 @@ export function CustomFieldsPanel() {
|
||||
}
|
||||
}}
|
||||
placeholder="New field name…"
|
||||
className="bg-slate-800 text-white"
|
||||
className="bg-muted text-foreground"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
@ -202,18 +202,18 @@ export function CustomFieldsPanel() {
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="max-h-72 overflow-y-auto rounded-md border border-slate-800">
|
||||
<div className="max-h-72 overflow-y-auto rounded-md border border-border">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-slate-500">
|
||||
<div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading…
|
||||
</div>
|
||||
) : fields.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-slate-500">
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No custom fields yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-800">
|
||||
<ul className="divide-y divide-border">
|
||||
{fields.map((field) => (
|
||||
<FieldRow
|
||||
key={field.id}
|
||||
@ -265,7 +265,7 @@ function FieldRow({
|
||||
if (e.key === 'Enter') e.currentTarget.blur();
|
||||
}}
|
||||
aria-label={`Rename ${field.field_name}`}
|
||||
className="focus:border-primary h-8 border-transparent bg-transparent text-white hover:border-slate-700"
|
||||
className="focus:border-primary h-8 border-transparent bg-transparent text-foreground hover:border-border"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -273,7 +273,7 @@ function FieldRow({
|
||||
disabled={busy}
|
||||
onClick={() => onDelete(field)}
|
||||
title="Delete field"
|
||||
className="shrink-0 text-slate-400 hover:text-red-400"
|
||||
className="shrink-0 text-muted-foreground hover:text-red-400"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import {
|
||||
@ -8,6 +8,16 @@ import {
|
||||
isUniqueViolation,
|
||||
normalizeKey,
|
||||
} from '@/lib/contacts/dedupe';
|
||||
import {
|
||||
parseContactCsv,
|
||||
type ParsedContactRow,
|
||||
} from '@/lib/contacts/parse-contact-csv';
|
||||
import {
|
||||
assignImportedContactTags,
|
||||
resolveImportTagIds,
|
||||
type ContactTagAssignment,
|
||||
} from '@/lib/contacts/resolve-import-tags';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
@ -18,7 +28,89 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Upload, FileText, Loader2, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
|
||||
import {
|
||||
Upload,
|
||||
FileText,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Tag,
|
||||
} from 'lucide-react';
|
||||
|
||||
const DEFAULT_TAG_COLOR = '#3b82f6';
|
||||
const PREVIEW_LIMIT = 5;
|
||||
|
||||
function truncateFilename(name: string, max = 48): string {
|
||||
if (name.length <= max) return name;
|
||||
const ext = name.includes('.') ? name.slice(name.lastIndexOf('.')) : '';
|
||||
const base = name.slice(0, name.length - ext.length);
|
||||
const keep = max - ext.length - 1;
|
||||
return `${base.slice(0, Math.max(keep, 12))}…${ext}`;
|
||||
}
|
||||
|
||||
function PreviewCell({
|
||||
value,
|
||||
mono,
|
||||
maxWidth = 'max-w-[9rem]',
|
||||
}: {
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
maxWidth?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'block truncate',
|
||||
maxWidth,
|
||||
mono && 'font-mono text-[11px]'
|
||||
)}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ImportPreviewTags({
|
||||
tagNames,
|
||||
tagColorByKey,
|
||||
}: {
|
||||
tagNames: string[];
|
||||
tagColorByKey: Map<string, string>;
|
||||
}) {
|
||||
if (tagNames.length === 0) {
|
||||
return <span className="text-muted-foreground">—</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-[4.5rem] flex-wrap gap-1">
|
||||
{tagNames.map((name) => {
|
||||
const color =
|
||||
tagColorByKey.get(name.trim().toLowerCase()) ?? DEFAULT_TAG_COLOR;
|
||||
const isKnown = tagColorByKey.has(name.trim().toLowerCase());
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
className="inline-flex max-w-full items-center gap-1 rounded-full px-2 py-0.5 text-[10px] leading-none font-medium"
|
||||
style={{
|
||||
backgroundColor: `${color}18`,
|
||||
color,
|
||||
border: `1px solid ${color}${isKnown ? '55' : '30'}`,
|
||||
}}
|
||||
title={isKnown ? name : `${name} (will be created on import)`}
|
||||
>
|
||||
<span
|
||||
className="size-1.5 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ImportModalProps {
|
||||
open: boolean;
|
||||
@ -26,87 +118,43 @@ interface ImportModalProps {
|
||||
onImported: () => void;
|
||||
}
|
||||
|
||||
interface ParsedRow {
|
||||
phone: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
company?: string;
|
||||
}
|
||||
|
||||
function parseCSV(text: string): ParsedRow[] {
|
||||
const lines = text.trim().split(/\r?\n/);
|
||||
if (lines.length < 2) return [];
|
||||
|
||||
const headerLine = lines[0];
|
||||
const headers = headerLine.split(',').map((h) => h.trim().toLowerCase().replace(/["']/g, ''));
|
||||
|
||||
const phoneIdx = headers.indexOf('phone');
|
||||
if (phoneIdx === -1) return [];
|
||||
|
||||
const nameIdx = headers.indexOf('name');
|
||||
const emailIdx = headers.indexOf('email');
|
||||
const companyIdx = headers.indexOf('company');
|
||||
|
||||
const rows: ParsedRow[] = [];
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Simple CSV parse (handles quoted fields)
|
||||
const values: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
for (const char of line) {
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
values.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
values.push(current.trim());
|
||||
|
||||
const phone = values[phoneIdx]?.replace(/["']/g, '').trim();
|
||||
if (!phone) continue;
|
||||
|
||||
rows.push({
|
||||
phone,
|
||||
name: nameIdx >= 0 ? values[nameIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
|
||||
email: emailIdx >= 0 ? values[emailIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
|
||||
company:
|
||||
companyIdx >= 0 ? values[companyIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) {
|
||||
export function ImportModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
onImported,
|
||||
}: ImportModalProps) {
|
||||
const supabase = createClient();
|
||||
const { accountId } = useAuth();
|
||||
const { accountId, canEditSettings } = useAuth();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||||
const [parsedRows, setParsedRows] = useState<ParsedContactRow[]>([]);
|
||||
const [hasTagsColumn, setHasTagsColumn] = useState(false);
|
||||
const [hasCompanyColumn, setHasCompanyColumn] = useState(false);
|
||||
const [tagColorByKey, setTagColorByKey] = useState<Map<string, string>>(
|
||||
new Map()
|
||||
);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<{
|
||||
imported: number;
|
||||
skipped: number;
|
||||
failed: number;
|
||||
tagsAssigned: number;
|
||||
} | null>(null);
|
||||
|
||||
function reset() {
|
||||
setFile(null);
|
||||
setParsedRows([]);
|
||||
setHasTagsColumn(false);
|
||||
setHasCompanyColumn(false);
|
||||
setTagColorByKey(new Map());
|
||||
setResult(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (!open) reset();
|
||||
onOpenChange(open);
|
||||
function handleOpenChange(next: boolean) {
|
||||
if (!next) reset();
|
||||
onOpenChange(next);
|
||||
}
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
@ -117,15 +165,42 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
setResult(null);
|
||||
|
||||
const text = await selected.text();
|
||||
const rows = parseCSV(text);
|
||||
const {
|
||||
rows,
|
||||
hasTagsColumn: csvHasTags,
|
||||
hasCompanyColumn: csvHasCompany,
|
||||
} = parseContactCsv(text);
|
||||
|
||||
if (rows.length === 0) {
|
||||
toast.error('No valid rows found. Ensure CSV has a "phone" column header.');
|
||||
toast.error(
|
||||
'No valid rows found. Ensure CSV has a "phone" column header.'
|
||||
);
|
||||
setParsedRows([]);
|
||||
setHasTagsColumn(false);
|
||||
setHasCompanyColumn(false);
|
||||
setTagColorByKey(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
setParsedRows(rows);
|
||||
setHasTagsColumn(csvHasTags);
|
||||
setHasCompanyColumn(csvHasCompany);
|
||||
|
||||
if (csvHasTags && accountId) {
|
||||
const { data: tags } = await supabase
|
||||
.from('tags')
|
||||
.select('name, color')
|
||||
.eq('account_id', accountId);
|
||||
|
||||
const colors = new Map<string, string>();
|
||||
for (const tag of tags ?? []) {
|
||||
const key = tag.name.trim().toLowerCase();
|
||||
if (!colors.has(key)) colors.set(key, tag.color);
|
||||
}
|
||||
setTagColorByKey(colors);
|
||||
} else {
|
||||
setTagColorByKey(new Map());
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
@ -138,7 +213,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
} = await supabase.auth.getSession();
|
||||
const user = session?.user;
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
if (!accountId) throw new Error('Your profile is not linked to an account.');
|
||||
if (!accountId)
|
||||
throw new Error('Your profile is not linked to an account.');
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
@ -156,8 +232,10 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
.eq('account_id', accountId);
|
||||
const existing = new Set(
|
||||
(existingRows ?? [])
|
||||
.map((r) => (r as { phone_normalized: string | null }).phone_normalized)
|
||||
.filter((p): p is string => !!p),
|
||||
.map(
|
||||
(r) => (r as { phone_normalized: string | null }).phone_normalized
|
||||
)
|
||||
.filter((p): p is string => !!p)
|
||||
);
|
||||
|
||||
const toInsert = unique.filter((row) => {
|
||||
@ -168,10 +246,27 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
return true;
|
||||
});
|
||||
|
||||
// 3) Batch insert the genuinely-new rows in chunks of 50. The DB
|
||||
// 3) Resolve tag names → ids (admin+ may auto-create missing tags).
|
||||
// Skip the round-trip when the import carries no tag names.
|
||||
const allTagNames = toInsert.flatMap((row) => row.tagNames);
|
||||
let tagIdByKey = new Map<string, string>();
|
||||
let skippedNames: string[] = [];
|
||||
if (allTagNames.length > 0) {
|
||||
({ tagIdByKey, skippedNames } = await resolveImportTagIds(supabase, {
|
||||
accountId,
|
||||
userId: user.id,
|
||||
tagNames: allTagNames,
|
||||
canCreateTags: canEditSettings,
|
||||
}));
|
||||
}
|
||||
|
||||
const tagAssignments: ContactTagAssignment[] = [];
|
||||
|
||||
// 4) Batch insert the genuinely-new rows in chunks of 50. The DB
|
||||
// unique index is the backstop: a 23505 (race, or a format
|
||||
// that normalizes equal) counts as skipped, not failed.
|
||||
const chunkSize = 50;
|
||||
|
||||
for (let i = 0; i < toInsert.length; i += chunkSize) {
|
||||
const chunk = toInsert.slice(i, i + chunkSize);
|
||||
const rows = chunk.map((row) => ({
|
||||
@ -191,10 +286,23 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
if (error) {
|
||||
// Retry individually so one bad/duplicate row doesn't sink
|
||||
// the whole chunk.
|
||||
for (const row of rows) {
|
||||
const { error: singleErr } = await supabase.from('contacts').insert(row);
|
||||
if (!singleErr) {
|
||||
for (let j = 0; j < rows.length; j++) {
|
||||
const row = rows[j];
|
||||
const source = chunk[j];
|
||||
const { data: singleData, error: singleErr } = await supabase
|
||||
.from('contacts')
|
||||
.insert(row)
|
||||
.select('id')
|
||||
.single();
|
||||
|
||||
if (!singleErr && singleData) {
|
||||
imported++;
|
||||
if (source.tagNames.length > 0) {
|
||||
tagAssignments.push({
|
||||
contactId: singleData.id,
|
||||
tagNames: source.tagNames,
|
||||
});
|
||||
}
|
||||
} else if (isUniqueViolation(singleErr)) {
|
||||
skipped++;
|
||||
} else {
|
||||
@ -202,20 +310,62 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imported += data?.length ?? chunk.length;
|
||||
const inserted = data ?? [];
|
||||
imported += inserted.length;
|
||||
// inserted[j] ↔ chunk[j] only holds because a single INSERT
|
||||
// preserves RETURNING order. If this path is ever split into
|
||||
// parallel inserts, zip by phone or returned id instead.
|
||||
for (let j = 0; j < inserted.length; j++) {
|
||||
const source = chunk[j];
|
||||
if (!source || source.tagNames.length === 0) continue;
|
||||
tagAssignments.push({
|
||||
contactId: inserted[j].id,
|
||||
tagNames: source.tagNames,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setResult({ imported, skipped, failed });
|
||||
// 5) Wire tags onto the contacts we just created. Failure here must
|
||||
// not mask a successful contact import.
|
||||
let tagsAssigned = 0;
|
||||
try {
|
||||
tagsAssigned = await assignImportedContactTags(
|
||||
supabase,
|
||||
tagAssignments,
|
||||
tagIdByKey
|
||||
);
|
||||
} catch {
|
||||
toast.warning('Contacts imported, but some tag assignments failed.');
|
||||
}
|
||||
|
||||
setResult({ imported, skipped, failed, tagsAssigned });
|
||||
if (imported > 0) {
|
||||
toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`);
|
||||
toast.success(
|
||||
`${imported} contact${imported !== 1 ? 's' : ''} imported`
|
||||
);
|
||||
onImported();
|
||||
}
|
||||
if (tagsAssigned > 0) {
|
||||
toast.success(
|
||||
`${tagsAssigned} tag assignment${tagsAssigned !== 1 ? 's' : ''} applied`
|
||||
);
|
||||
}
|
||||
if (skippedNames.length > 0) {
|
||||
const sample = skippedNames.slice(0, 3).join(', ');
|
||||
const more =
|
||||
skippedNames.length > 3 ? ` (+${skippedNames.length - 3} more)` : '';
|
||||
toast.info(
|
||||
`Unknown tags skipped (create them in Settings first): ${sample}${more}`
|
||||
);
|
||||
}
|
||||
if (skipped > 0) {
|
||||
toast.info(`${skipped} duplicate${skipped !== 1 ? 's' : ''} skipped`);
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.error(`${failed} contact${failed !== 1 ? 's' : ''} failed to import`);
|
||||
toast.error(
|
||||
`${failed} contact${failed !== 1 ? 's' : ''} failed to import`
|
||||
);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Import failed';
|
||||
@ -225,41 +375,101 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
}
|
||||
}
|
||||
|
||||
const preview = parsedRows.slice(0, 5);
|
||||
const preview = parsedRows.slice(0, PREVIEW_LIMIT);
|
||||
// Tags: OR — show when the CSV declares a column or preview rows carry
|
||||
// values, so an all-empty tags column still renders for validation.
|
||||
const previewHasTags =
|
||||
hasTagsColumn || preview.some((row) => row.tagNames.length > 0);
|
||||
// Company: AND — hide unless the CSV declares it and preview has data,
|
||||
// avoiding an all-dash column that wastes horizontal space.
|
||||
const previewHasCompany =
|
||||
hasCompanyColumn && preview.some((row) => row.company?.trim());
|
||||
|
||||
const tagStats = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
let rowsWithTags = 0;
|
||||
for (const row of parsedRows) {
|
||||
if (row.tagNames.length === 0) continue;
|
||||
rowsWithTags++;
|
||||
for (const name of row.tagNames) names.add(name.trim().toLowerCase());
|
||||
}
|
||||
return { unique: names.size, rowsWithTags };
|
||||
}, [parsedRows]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Import Contacts</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Upload a CSV file with a "phone" column (required). Optional columns:
|
||||
name, email, company.
|
||||
<DialogContent className="flex max-h-[min(90vh,720px)] flex-col gap-0 overflow-hidden border-border/80 bg-popover p-0 text-popover-foreground sm:max-w-2xl">
|
||||
<div className="shrink-0 space-y-4 border-b border-border/80 px-6 pt-6 pb-5">
|
||||
<DialogHeader className="gap-1.5">
|
||||
<DialogTitle className="text-lg text-popover-foreground">
|
||||
Import Contacts
|
||||
</DialogTitle>
|
||||
<DialogDescription className="leading-relaxed text-muted-foreground">
|
||||
Upload a CSV with a required{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
|
||||
phone
|
||||
</code>{' '}
|
||||
column. Optional:{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
|
||||
name
|
||||
</code>
|
||||
,{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
|
||||
email
|
||||
</code>
|
||||
,{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
|
||||
company
|
||||
</code>
|
||||
,{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[11px] text-muted-foreground">
|
||||
tags
|
||||
</code>{' '}
|
||||
(comma-separated; quote multi-tag cells).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Upload area */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-700 p-6 cursor-pointer hover:border-primary/50 transition-colors"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ')
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
className={cn(
|
||||
'group flex cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border border-dashed p-5 transition-all',
|
||||
file
|
||||
? 'border-primary/35 bg-primary/[0.04]'
|
||||
: 'hover:border-primary/40 border-border/80 bg-background/40 hover:bg-background/70'
|
||||
)}
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
<FileText className="size-8 text-primary" />
|
||||
<p className="text-sm text-slate-300">{file.name}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} detected
|
||||
<div className="bg-primary/15 ring-primary/25 flex size-10 items-center justify-center rounded-lg ring-1">
|
||||
<FileText className="text-primary size-5" />
|
||||
</div>
|
||||
<p
|
||||
className="max-w-full truncate px-2 text-sm font-medium text-popover-foreground"
|
||||
title={file.name}
|
||||
>
|
||||
{truncateFilename(file.name)}
|
||||
</p>
|
||||
<span className="rounded-full bg-muted px-2.5 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''}{' '}
|
||||
ready
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="size-8 text-slate-500" />
|
||||
<p className="text-sm text-slate-400">
|
||||
Click to upload CSV file
|
||||
<div className="flex size-10 items-center justify-center rounded-lg bg-muted/80 ring-1 ring-border/80 transition-colors group-hover:bg-muted">
|
||||
<Upload className="size-5 text-muted-foreground group-hover:text-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click to choose a CSV file
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
CSV with "phone" column required
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
.csv up to your browser limit
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@ -272,63 +482,136 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview table */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
||||
{preview.length > 0 && !result && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||
Preview (first {preview.length} rows)
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold tracking-[0.14em] text-muted-foreground uppercase">
|
||||
Preview · first {preview.length}
|
||||
</p>
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{tagStats.rowsWithTags > 0 && (
|
||||
<span className="inline-flex items-center gap-1 rounded-md bg-muted/90 px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
<Tag className="text-primary/80 size-3" />
|
||||
{tagStats.unique} tag{tagStats.unique !== 1 ? 's' : ''} ·{' '}
|
||||
{tagStats.rowsWithTags} contact
|
||||
{tagStats.rowsWithTags !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-border ring-1 ring-border/50">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[32rem] text-xs">
|
||||
<thead>
|
||||
<tr className="bg-slate-800">
|
||||
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Phone</th>
|
||||
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Name</th>
|
||||
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Email</th>
|
||||
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Company</th>
|
||||
<tr className="border-b border-border bg-background/60">
|
||||
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
|
||||
Phone
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
|
||||
Email
|
||||
</th>
|
||||
{previewHasCompany && (
|
||||
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
|
||||
Company
|
||||
</th>
|
||||
)}
|
||||
{previewHasTags && (
|
||||
<th className="px-3 py-2 text-left font-medium whitespace-nowrap text-muted-foreground">
|
||||
Tags
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-border/70">
|
||||
{preview.map((row, i) => (
|
||||
<tr key={i} className="border-t border-slate-700/50">
|
||||
<td className="px-3 py-1.5 text-slate-300">{row.phone}</td>
|
||||
<td className="px-3 py-1.5 text-slate-300">{row.name || '-'}</td>
|
||||
<td className="px-3 py-1.5 text-slate-300">{row.email || '-'}</td>
|
||||
<td className="px-3 py-1.5 text-slate-300">{row.company || '-'}</td>
|
||||
<tr
|
||||
key={i}
|
||||
className="bg-popover/40 transition-colors hover:bg-muted/30"
|
||||
>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
<PreviewCell
|
||||
value={row.phone}
|
||||
mono
|
||||
maxWidth="max-w-[7.5rem]"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-popover-foreground">
|
||||
<PreviewCell
|
||||
value={row.name || '—'}
|
||||
maxWidth="max-w-[8.5rem]"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
<PreviewCell
|
||||
value={row.email || '—'}
|
||||
maxWidth="max-w-[10rem]"
|
||||
/>
|
||||
</td>
|
||||
{previewHasCompany && (
|
||||
<td className="px-3 py-2 text-muted-foreground">
|
||||
<PreviewCell
|
||||
value={row.company || '—'}
|
||||
maxWidth="max-w-[7rem]"
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{previewHasTags && (
|
||||
<td className="px-3 py-2 align-top">
|
||||
<ImportPreviewTags
|
||||
tagNames={row.tagNames}
|
||||
tagColorByKey={tagColorByKey}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{parsedRows.length > 5 && (
|
||||
<p className="text-xs text-slate-500">
|
||||
...and {parsedRows.length - 5} more rows
|
||||
</div>
|
||||
|
||||
{parsedRows.length > PREVIEW_LIMIT && (
|
||||
<p className="text-center text-[11px] text-muted-foreground">
|
||||
+ {parsedRows.length - PREVIEW_LIMIT} more row
|
||||
{parsedRows.length - PREVIEW_LIMIT !== 1 ? 's' : ''} not shown
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{result && (
|
||||
<div className="rounded-lg border border-slate-700 p-4 space-y-2">
|
||||
<p className="text-sm font-medium text-white">Import Complete</p>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="rounded-xl border border-border bg-background/50 p-4">
|
||||
<p className="text-sm font-medium text-popover-foreground">Import complete</p>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
{result.imported > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-primary text-sm">
|
||||
<CheckCircle className="size-4" />
|
||||
<div className="text-primary flex items-center gap-1.5 text-sm">
|
||||
<CheckCircle className="size-4 shrink-0" />
|
||||
{result.imported} imported
|
||||
</div>
|
||||
)}
|
||||
{result.tagsAssigned > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-sm text-cyan-400">
|
||||
<CheckCircle className="size-4 shrink-0" />
|
||||
{result.tagsAssigned} tag
|
||||
{result.tagsAssigned !== 1 ? 's' : ''} assigned
|
||||
</div>
|
||||
)}
|
||||
{result.skipped > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-amber-400 text-sm">
|
||||
<AlertTriangle className="size-4" />
|
||||
{result.skipped} duplicate{result.skipped !== 1 ? 's' : ''} skipped
|
||||
<div className="flex items-center gap-1.5 text-sm text-amber-400">
|
||||
<AlertTriangle className="size-4 shrink-0" />
|
||||
{result.skipped} skipped
|
||||
</div>
|
||||
)}
|
||||
{result.failed > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-red-400 text-sm">
|
||||
<XCircle className="size-4" />
|
||||
<div className="flex items-center gap-1.5 text-sm text-red-400">
|
||||
<XCircle className="size-4 shrink-0" />
|
||||
{result.failed} failed
|
||||
</div>
|
||||
)}
|
||||
@ -337,12 +620,12 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<DialogFooter className="mt-0 shrink-0 gap-2 border-t border-border/80 bg-background/50 px-6 py-4 sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
{result ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
@ -354,7 +637,8 @@ export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
{importing && <Loader2 className="size-4 animate-spin" />}
|
||||
Import {parsedRows.length > 0 ? `${parsedRows.length} Contacts` : ''}
|
||||
Import {parsedRows.length > 0 ? parsedRows.length : ''} contact
|
||||
{parsedRows.length !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@ -54,9 +54,9 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
i === 0 || totalLoaded > PAGE_SIZES[i - 1]
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-white">Recent Activity</h2>
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<header className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">Recent Activity</h2>
|
||||
<Link
|
||||
href="/inbox"
|
||||
className="text-xs font-medium text-primary hover:text-primary/80"
|
||||
@ -81,13 +81,14 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ul className="divide-y divide-slate-800">
|
||||
<ul className="divide-y divide-border">
|
||||
{visible.map((it, i) => {
|
||||
const theme = KIND_THEME[it.kind]
|
||||
const Icon = theme.icon
|
||||
// Alternating row background for scanability — dark-theme
|
||||
// translation of the spec's white / #f9fafb stripes.
|
||||
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-slate-900/40'
|
||||
// Alternating row background for scanability. bg-muted/40
|
||||
// keeps the stripe visible in both light and dark modes
|
||||
// (bg-card/40 vanishes against a white card surface in light).
|
||||
const stripe = i % 2 === 0 ? 'bg-transparent' : 'bg-muted/40'
|
||||
const row = (
|
||||
<div className="flex items-center gap-3 px-5 py-2.5">
|
||||
<span
|
||||
@ -98,16 +99,16 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-slate-200">
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
{it.text}
|
||||
</span>
|
||||
<span className="flex-shrink-0 text-xs text-slate-500 tabular-nums">
|
||||
<span className="flex-shrink-0 text-xs text-muted-foreground tabular-nums">
|
||||
{relativeTime(it.at)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<li key={it.id} className={cn(stripe, 'transition-colors hover:bg-slate-800/40')}>
|
||||
<li key={it.id} className={cn(stripe, 'transition-colors hover:bg-muted/40')}>
|
||||
{it.href ? (
|
||||
<Link href={it.href} className="block">
|
||||
{row}
|
||||
@ -119,13 +120,13 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<footer className="flex items-center justify-between border-t border-slate-800 px-5 py-3 text-xs">
|
||||
<span className="text-slate-500 tabular-nums">
|
||||
<footer className="flex items-center justify-between border-t border-border px-5 py-3 text-xs">
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
Showing {visible.length} of {totalLoaded}
|
||||
{totalLoaded === 50 ? '+' : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="mr-1 text-slate-500">Show</span>
|
||||
<span className="mr-1 text-muted-foreground">Show</span>
|
||||
{PAGE_SIZES.map((size, i) => {
|
||||
const disabled = !isSizeUseful(size, i)
|
||||
return (
|
||||
@ -137,9 +138,9 @@ export function ActivityFeed({ items, loading }: ActivityFeedProps) {
|
||||
className={cn(
|
||||
'rounded-md px-2 py-1 font-medium tabular-nums transition-colors',
|
||||
pageSize === size
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white',
|
||||
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-slate-400',
|
||||
? 'bg-secondary text-secondary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
disabled && 'cursor-not-allowed opacity-40 hover:bg-transparent hover:text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{size}
|
||||
|
||||
@ -46,13 +46,13 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<section className="flex h-full flex-col rounded-xl border border-slate-800 bg-slate-900">
|
||||
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
|
||||
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
|
||||
<header className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">Conversations Over Time</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-500">Daily message volume by direction</p>
|
||||
<h2 className="text-sm font-semibold text-foreground">Conversations Over Time</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">Daily message volume by direction</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 rounded-lg bg-slate-800/60 p-1">
|
||||
<div className="flex items-center gap-1 rounded-lg bg-muted/60 p-1">
|
||||
{[7, 30, 90].map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
@ -61,8 +61,8 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
|
||||
className={cn(
|
||||
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
range === r
|
||||
? 'bg-slate-700 text-white'
|
||||
: 'text-slate-400 hover:text-white',
|
||||
? 'bg-secondary text-secondary-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{r} days
|
||||
@ -85,7 +85,7 @@ export function ConversationsChart({ series, loading, range, onRangeChange }: Co
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center gap-4 border-t border-slate-800 px-5 py-3 text-xs text-slate-400">
|
||||
<footer className="flex items-center gap-4 border-t border-border px-5 py-3 text-xs text-muted-foreground">
|
||||
<LegendDot color="#3b82f6" label="Incoming" />
|
||||
<LegendDot color="#7c3aed" label="Outgoing" />
|
||||
</footer>
|
||||
@ -207,7 +207,7 @@ function LineSvg({
|
||||
x2={VB_W - PADDING.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="rgb(30 41 59)"
|
||||
stroke="var(--border)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<text
|
||||
@ -215,7 +215,7 @@ function LineSvg({
|
||||
y={y}
|
||||
textAnchor="end"
|
||||
dominantBaseline="middle"
|
||||
className="fill-slate-500 text-[10px]"
|
||||
className="fill-muted-foreground text-[10px]"
|
||||
>
|
||||
{t}
|
||||
</text>
|
||||
@ -231,7 +231,7 @@ function LineSvg({
|
||||
x={xFor(i)}
|
||||
y={VB_H - 8}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-[10px]"
|
||||
className="fill-muted-foreground text-[10px]"
|
||||
>
|
||||
{shortDayLabel(p.day)}
|
||||
</text>
|
||||
@ -265,7 +265,7 @@ function LineSvg({
|
||||
x2={hoverX}
|
||||
y1={PADDING.top}
|
||||
y2={PADDING.top + chartH}
|
||||
stroke="rgb(71 85 105)"
|
||||
stroke="var(--muted-foreground)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<circle cx={hoverX} cy={yFor(data[hover.idx].incoming)} r={3.5} fill="#3b82f6" />
|
||||
@ -280,10 +280,10 @@ function LineSvg({
|
||||
letterboxed viewBox percentage. */}
|
||||
{hovered && hover !== null && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-slate-700 bg-slate-950 px-2.5 py-1.5 text-[11px] shadow-lg"
|
||||
className="pointer-events-none absolute top-0 z-10 -translate-x-1/2 rounded-md border border-border bg-popover px-2.5 py-1.5 text-[11px] shadow-lg"
|
||||
style={{ left: `${hover.tooltipLeftPx}px` }}
|
||||
>
|
||||
<div className="font-medium text-white">{longDayLabel(hovered.day)}</div>
|
||||
<div className="font-medium text-popover-foreground">{longDayLabel(hovered.day)}</div>
|
||||
<div className="mt-1 flex flex-col gap-0.5">
|
||||
<span className="flex items-center gap-1.5 text-blue-300">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-blue-500" />
|
||||
|
||||
@ -22,15 +22,15 @@ export function EmptyState({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-full min-h-40 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-slate-800 bg-slate-900/40 px-4 py-6 text-center',
|
||||
'flex h-full min-h-40 flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-border bg-card/40 px-4 py-6 text-center',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-slate-800 text-slate-500">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-muted text-muted-foreground">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-slate-300">{title}</p>
|
||||
{hint && <p className="max-w-xs text-xs text-slate-500">{hint}</p>}
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
{hint && <p className="max-w-xs text-xs text-muted-foreground">{hint}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -23,18 +23,18 @@ interface MetricCardProps {
|
||||
|
||||
export function MetricCard({ title, value, icon: Icon, delta, subtitle }: MetricCardProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<div className="rounded-xl border border-border bg-card p-5">
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-sm font-medium text-slate-400">{title}</p>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-800 text-slate-500">
|
||||
<p className="text-sm font-medium text-muted-foreground">{title}</p>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 text-[28px] leading-none font-bold tabular-nums text-white">
|
||||
<p className="mt-3 text-[28px] leading-none font-bold tabular-nums text-foreground">
|
||||
{value}
|
||||
</p>
|
||||
{delta ? <DeltaRow sign={delta.sign} label={delta.label} /> : subtitle ? (
|
||||
<p className="mt-2 text-sm text-slate-500">{subtitle}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{subtitle}</p>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
@ -46,7 +46,7 @@ function DeltaRow({ sign, label }: { sign: number; label: string }) {
|
||||
? 'text-primary'
|
||||
: sign < 0
|
||||
? 'text-red-400'
|
||||
: 'text-slate-500'
|
||||
: 'text-muted-foreground'
|
||||
const Arrow = sign > 0 ? ArrowUp : sign < 0 ? ArrowDown : Minus
|
||||
return (
|
||||
<div className={cn('mt-2 flex items-center gap-1 text-sm', tone)}>
|
||||
|
||||
@ -15,10 +15,10 @@ interface PipelineDonutProps {
|
||||
|
||||
export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
||||
return (
|
||||
<section className="flex h-full flex-col rounded-xl border border-slate-800 bg-slate-900">
|
||||
<header className="border-b border-slate-800 px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-white">Pipeline Value</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
<section className="flex h-full flex-col rounded-xl border border-border bg-card">
|
||||
<header className="border-b border-border px-5 py-4">
|
||||
<h2 className="text-sm font-semibold text-foreground">Pipeline Value</h2>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Open deals by stage
|
||||
</p>
|
||||
</header>
|
||||
@ -43,11 +43,11 @@ export function PipelineDonut({ data, loading, currency }: PipelineDonutProps) {
|
||||
style={{ background: s.color }}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="flex-1 truncate text-slate-300">{s.name}</span>
|
||||
<span className="text-slate-500 tabular-nums">
|
||||
<span className="flex-1 truncate text-muted-foreground">{s.name}</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{s.dealCount} deal{s.dealCount === 1 ? '' : 's'}
|
||||
</span>
|
||||
<span className="w-20 text-right text-slate-300 tabular-nums">
|
||||
<span className="w-20 text-right text-muted-foreground tabular-nums">
|
||||
{formatCurrencyShort(s.totalValue, currency)}
|
||||
</span>
|
||||
</li>
|
||||
@ -98,7 +98,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
|
||||
<div className="flex items-center justify-center">
|
||||
<svg viewBox={`0 0 ${size} ${size}`} className="h-48 w-48" role="img" aria-label="Pipeline value by stage">
|
||||
{/* background ring */}
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="rgb(30 41 59)" strokeWidth={ringWidth} />
|
||||
<circle cx={cx} cy={cy} r={r} fill="none" stroke="var(--muted)" strokeWidth={ringWidth} />
|
||||
{segments.map((seg) => (
|
||||
<path
|
||||
key={seg.id}
|
||||
@ -114,7 +114,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
|
||||
x={cx}
|
||||
y={cy - 6}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-[11px]"
|
||||
className="fill-muted-foreground text-[11px]"
|
||||
>
|
||||
Total
|
||||
</text>
|
||||
@ -122,7 +122,7 @@ function Donut({ data, currency }: { data: PipelineDonutData; currency: string }
|
||||
x={cx}
|
||||
y={cy + 14}
|
||||
textAnchor="middle"
|
||||
className="fill-white text-[18px] font-semibold tabular-nums"
|
||||
className="fill-foreground text-[18px] font-semibold tabular-nums"
|
||||
>
|
||||
{formatCurrencyShort(data.totalValue, currency)}
|
||||
</text>
|
||||
|
||||
@ -31,12 +31,12 @@ export function QuickActions() {
|
||||
<Link
|
||||
key={a.href}
|
||||
href={a.href}
|
||||
className="group flex items-center gap-3 rounded-xl border border-slate-800 bg-slate-900 px-4 py-3 transition-colors hover:border-slate-700 hover:bg-slate-800/60"
|
||||
className="group flex items-center gap-3 rounded-xl border border-border bg-card px-4 py-3 transition-colors hover:border-border hover:bg-muted/60"
|
||||
>
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-slate-800 ${a.tint}`}>
|
||||
<div className={`flex h-9 w-9 items-center justify-center rounded-lg bg-muted ${a.tint}`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-medium text-white">{a.label}</span>
|
||||
<span className="text-sm font-medium text-foreground">{a.label}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
|
||||
@ -44,13 +44,13 @@ export function ResponseTimeChart({
|
||||
})) ?? []
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<header className="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4">
|
||||
<section className="rounded-xl border border-border bg-card">
|
||||
<header className="flex items-center justify-between gap-3 border-b border-border px-5 py-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
<h2 className="text-sm font-semibold text-foreground">
|
||||
Average First Response Time
|
||||
</h2>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Minutes to reply to a customer's first unreplied message, by
|
||||
weekday
|
||||
</p>
|
||||
@ -63,13 +63,13 @@ export function ResponseTimeChart({
|
||||
)}
|
||||
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
|
||||
<div>
|
||||
<div className="text-slate-400">
|
||||
<div className="text-muted-foreground">
|
||||
This week:{' '}
|
||||
<span className="font-medium text-white tabular-nums">
|
||||
<span className="font-medium text-foreground tabular-nums">
|
||||
{fmt(data.thisWeekAvg)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-slate-500">
|
||||
<div className="text-muted-foreground">
|
||||
Last week:{' '}
|
||||
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
|
||||
</div>
|
||||
|
||||
@ -6,14 +6,14 @@ import { cn } from '@/lib/utils'
|
||||
* its data fetches.
|
||||
*/
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn('animate-pulse rounded-md bg-slate-800', className)} />
|
||||
return <div className={cn('animate-pulse rounded-md bg-muted', className)} />
|
||||
}
|
||||
|
||||
export function SkeletonCard({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-xl border border-slate-800 bg-slate-900 p-5',
|
||||
'rounded-xl border border-border bg-card p-5',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Linear-list flow editor.
|
||||
@ -17,7 +17,7 @@
|
||||
* are list-only and have no canvas analogue.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
CircleAlert,
|
||||
Plus,
|
||||
@ -25,39 +25,41 @@ import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
CornerDownRight,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type ValidationIssue } from "@/lib/flows/validate";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type ValidationIssue } from '@/lib/flows/validate';
|
||||
import {
|
||||
NODE_META,
|
||||
NodeIconChip,
|
||||
groupNodeTypesByCategory,
|
||||
nodeColors,
|
||||
slugify,
|
||||
summarizeNode,
|
||||
type BuilderNode,
|
||||
type NodeType,
|
||||
} from "./shared";
|
||||
import { NodeConfigForm } from "./forms/node-config-form";
|
||||
import { NodeKeySelect } from "./forms/fields";
|
||||
import { IssueLine } from "./validation-panel";
|
||||
import {
|
||||
useFlowEditor,
|
||||
type BuilderState,
|
||||
} from "./flow-editor-state";
|
||||
} from './shared';
|
||||
import { NodeConfigForm } from './forms/node-config-form';
|
||||
import { NodeKeySelect } from './forms/fields';
|
||||
import { IssueLine } from './validation-panel';
|
||||
import { useFlowEditor, type BuilderState } from './flow-editor-state';
|
||||
|
||||
// ============================================================
|
||||
// Local state shape — mirrors the DB but the configs are typed
|
||||
@ -85,7 +87,7 @@ export function FlowBuilder() {
|
||||
// jump-to-node. The flash itself is read from context (flashKey)
|
||||
// so canvas + list share the same source of truth.
|
||||
const [expanded, setExpanded] = useState<Set<string>>(
|
||||
() => new Set(state.nodes.map((n) => n.node_key)),
|
||||
() => new Set(state.nodes.map((n) => n.node_key))
|
||||
);
|
||||
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
@ -97,7 +99,7 @@ export function FlowBuilder() {
|
||||
const key = addNodeCtx(type);
|
||||
setExpanded((prev) => new Set([...prev, key]));
|
||||
},
|
||||
[addNodeCtx],
|
||||
[addNodeCtx]
|
||||
);
|
||||
|
||||
const removeNode = useCallback(
|
||||
@ -109,7 +111,7 @@ export function FlowBuilder() {
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[removeNodeCtx],
|
||||
[removeNodeCtx]
|
||||
);
|
||||
|
||||
const toggleExpanded = useCallback((key: string) => {
|
||||
@ -126,7 +128,7 @@ export function FlowBuilder() {
|
||||
if (el) nodeRefs.current.set(key, el);
|
||||
else nodeRefs.current.delete(key);
|
||||
},
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
// React to validator jumps via the shared flashKey. We DERIVE the
|
||||
@ -145,33 +147,33 @@ export function FlowBuilder() {
|
||||
// committed any expand-induced layout shift.
|
||||
requestAnimationFrame(() => {
|
||||
const el = nodeRefs.current.get(flashKey);
|
||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
});
|
||||
}, [flashKey]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-7">
|
||||
<TriggerPanel
|
||||
state={state}
|
||||
setState={setState}
|
||||
triggerIssues={issues.filter((i) => i.scope === "trigger")}
|
||||
triggerIssues={issues.filter((i) => i.scope === 'trigger')}
|
||||
/>
|
||||
|
||||
<EntryPicker state={state} setState={setState} />
|
||||
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
<h2 className="text-foreground text-sm font-semibold">
|
||||
Nodes ({state.nodes.length})
|
||||
</h2>
|
||||
<AddNodeButton onAdd={addNode} />
|
||||
</div>
|
||||
|
||||
{state.nodes.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-700 bg-slate-900/50 p-8 text-center text-sm text-slate-400">
|
||||
Add a <strong>Start</strong> node, then a <strong>Send buttons</strong>
|
||||
{" "}node, then a <strong>Handoff</strong> — that's the welcome-menu
|
||||
shape from the brief.
|
||||
<div className="border-border bg-card/50 text-muted-foreground rounded-lg border border-dashed p-8 text-center text-sm">
|
||||
Add a <strong>Start</strong> node, then a{' '}
|
||||
<strong>Send buttons</strong> node, then a <strong>Handoff</strong>{' '}
|
||||
— that's the welcome-menu shape from the brief.
|
||||
</div>
|
||||
) : (
|
||||
state.nodes.map((node) => (
|
||||
@ -184,7 +186,7 @@ export function FlowBuilder() {
|
||||
isFlashed={flashKey === node.node_key}
|
||||
cardRef={setNodeRef(node.node_key)}
|
||||
issues={issues.filter(
|
||||
(i) => i.scope === "node" && i.node_key === node.node_key,
|
||||
(i) => i.scope === 'node' && i.node_key === node.node_key
|
||||
)}
|
||||
onToggle={() => toggleExpanded(node.node_key)}
|
||||
onUpdate={(patch) => updateNode(node.node_key, patch)}
|
||||
@ -201,7 +203,6 @@ export function FlowBuilder() {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Keyword trigger input
|
||||
// ============================================================
|
||||
@ -223,14 +224,14 @@ function KeywordsInput({
|
||||
keywords: string[];
|
||||
onChange: (keywords: string[]) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState(keywords.join(", "));
|
||||
const [draft, setDraft] = useState(keywords.join(', '));
|
||||
|
||||
function commit() {
|
||||
const parsed = draft
|
||||
.split(",")
|
||||
.split(',')
|
||||
.map((k) => k.trim())
|
||||
.filter(Boolean);
|
||||
setDraft(parsed.join(", "));
|
||||
setDraft(parsed.join(', '));
|
||||
onChange(parsed);
|
||||
}
|
||||
|
||||
@ -240,13 +241,13 @@ function KeywordsInput({
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
}}
|
||||
placeholder="support, help, hi"
|
||||
className="bg-slate-800"
|
||||
className="bg-muted"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -265,23 +266,25 @@ function TriggerPanel({
|
||||
triggerIssues: ValidationIssue[];
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border border-slate-800 bg-slate-900 p-4">
|
||||
<h2 className="mb-3 text-sm font-semibold text-white">Trigger</h2>
|
||||
<section className="border-border bg-card rounded-lg border p-4">
|
||||
<h2 className="text-foreground mb-3 text-sm font-semibold">Trigger</h2>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">When…</label>
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
When…
|
||||
</label>
|
||||
<Select
|
||||
value={state.trigger_type}
|
||||
onValueChange={(v) =>
|
||||
setState((s) => ({
|
||||
...s,
|
||||
trigger_type: v as BuilderState["trigger_type"],
|
||||
trigger_type: v as BuilderState['trigger_type'],
|
||||
trigger_config:
|
||||
v === "keyword" ? { keywords: [] } : v === "manual" ? {} : {},
|
||||
v === 'keyword' ? { keywords: [] } : v === 'manual' ? {} : {},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-800">
|
||||
<SelectTrigger className="bg-muted">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -297,9 +300,9 @@ function TriggerPanel({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{state.trigger_type === "keyword" && (
|
||||
{state.trigger_type === 'keyword' && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
Keywords (comma-separated)
|
||||
</label>
|
||||
<KeywordsInput
|
||||
@ -342,17 +345,15 @@ function EntryPicker({
|
||||
}) {
|
||||
if (state.nodes.length === 0) return null;
|
||||
return (
|
||||
<section className="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-900 p-3">
|
||||
<CornerDownRight className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="text-xs text-slate-400">Entry node:</span>
|
||||
<section className="border-border bg-card flex items-center gap-3 rounded-lg border p-3">
|
||||
<CornerDownRight className="text-primary h-4 w-4 shrink-0" />
|
||||
<span className="text-muted-foreground text-xs">Entry node:</span>
|
||||
<NodeKeySelect
|
||||
value={state.entry_node_id}
|
||||
nodes={state.nodes}
|
||||
onChange={(key) =>
|
||||
setState((s) => ({ ...s, entry_node_id: key }))
|
||||
}
|
||||
onChange={(key) => setState((s) => ({ ...s, entry_node_id: key }))}
|
||||
placeholder="Pick the first node…"
|
||||
className="flex-1 max-w-xs"
|
||||
className="max-w-xs flex-1"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
@ -390,47 +391,55 @@ function NodeCard({
|
||||
onSetEntry: () => void;
|
||||
}) {
|
||||
const meta = NODE_META[node.node_type];
|
||||
const hasError = issues.some((i) => i.severity === "error");
|
||||
const c = nodeColors(node.node_type);
|
||||
const hasError = issues.some((i) => i.severity === 'error');
|
||||
const preview = summarizeNode(node);
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
"rounded-lg border bg-slate-900 transition-shadow duration-500",
|
||||
'bg-card relative overflow-hidden rounded-xl border transition-shadow duration-500',
|
||||
hasError
|
||||
? "border-red-500/40"
|
||||
? 'border-red-500/40'
|
||||
: isEntry
|
||||
? "border-primary/50"
|
||||
: "border-slate-800",
|
||||
isFlashed &&
|
||||
"ring-2 ring-primary ring-offset-2 ring-offset-slate-950",
|
||||
? 'border-primary/50'
|
||||
: 'border-border',
|
||||
isFlashed && 'ring-primary ring-offset-background ring-2 ring-offset-2'
|
||||
)}
|
||||
>
|
||||
{/* type-colored left rail, ties the list row to the canvas hue */}
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 w-[3px]"
|
||||
style={{ background: c.solid }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-left"
|
||||
className="flex w-full items-center gap-3 px-4 py-3 pl-5 text-left"
|
||||
>
|
||||
<meta.icon className={cn("h-4 w-4 shrink-0", meta.color)} />
|
||||
<NodeIconChip type={node.node_type} size={32} iconSize={16} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-white">
|
||||
<span
|
||||
className="truncate text-[11px] font-semibold tracking-wider uppercase"
|
||||
style={{ color: c.text }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
<code className="rounded bg-slate-800 px-1.5 py-0.5 text-[10px] text-slate-400">
|
||||
<code className="bg-muted text-muted-foreground rounded px-1.5 py-0.5 text-[10px]">
|
||||
{node.node_key}
|
||||
</code>
|
||||
{isEntry && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-primary/40 bg-primary/10 text-[10px] text-primary"
|
||||
className="border-primary/40 bg-primary/10 text-primary text-[10px]"
|
||||
>
|
||||
Entry
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{!expanded && preview && (
|
||||
<p className="mt-0.5 truncate text-xs text-slate-500">
|
||||
<p className="text-muted-foreground mt-0.5 truncate text-xs">
|
||||
{preview}
|
||||
</p>
|
||||
)}
|
||||
@ -439,20 +448,20 @@ function NodeCard({
|
||||
<CircleAlert className="h-3.5 w-3.5 shrink-0 text-red-400" />
|
||||
)}
|
||||
{expanded ? (
|
||||
<ChevronUp className="h-4 w-4 text-slate-500" />
|
||||
<ChevronUp className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-slate-500" />
|
||||
<ChevronDown className="text-muted-foreground h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-slate-800 px-4 py-4">
|
||||
<div className="border-border border-t px-4 py-4">
|
||||
<NodeConfigWithAdvanced
|
||||
node={node}
|
||||
allNodes={allNodes}
|
||||
onUpdate={onUpdate}
|
||||
onUpdateConfig={onUpdateConfig}
|
||||
/>
|
||||
<div className="mt-4 flex items-center justify-between border-t border-slate-800 pt-3">
|
||||
<div className="border-border mt-4 flex items-center justify-between border-t pt-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEntry && (
|
||||
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
||||
@ -502,7 +511,7 @@ function NodeConfigWithAdvanced({
|
||||
}) {
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const hasReplyIds =
|
||||
node.node_type === "send_buttons" || node.node_type === "send_list";
|
||||
node.node_type === 'send_buttons' || node.node_type === 'send_list';
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<NodeConfigForm
|
||||
@ -511,23 +520,23 @@ function NodeConfigWithAdvanced({
|
||||
showAdvanced={showAdvanced}
|
||||
onUpdateConfig={onUpdateConfig}
|
||||
/>
|
||||
<div className="border-t border-slate-800 pt-3">
|
||||
<div className="border-border border-t pt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-slate-300"
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
{showAdvanced ? "Hide" : "Show"} advanced
|
||||
{showAdvanced ? 'Hide' : 'Show'} advanced
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">
|
||||
<label className="text-muted-foreground mb-1 block text-xs">
|
||||
Node key (internal identifier — keep stable for analytics)
|
||||
</label>
|
||||
<Input
|
||||
@ -535,14 +544,14 @@ function NodeConfigWithAdvanced({
|
||||
onChange={(e) =>
|
||||
onUpdate({ node_key: slugify(e.target.value, node.node_key) })
|
||||
}
|
||||
className="bg-slate-800 font-mono text-xs"
|
||||
className="bg-muted font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
{hasReplyIds && (
|
||||
<p className="text-[10px] text-slate-500">
|
||||
<p className="text-muted-foreground text-[10px]">
|
||||
Reply IDs for each option are shown inline above. They're
|
||||
returned by WhatsApp when a customer taps; you usually don't
|
||||
need to touch them.
|
||||
returned by WhatsApp when a customer taps; you usually
|
||||
don't need to touch them.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -552,45 +561,51 @@ function NodeConfigWithAdvanced({
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ============================================================
|
||||
// Add-node menu
|
||||
// ============================================================
|
||||
|
||||
function AddNodeButton({ onAdd }: { onAdd: (type: NodeType) => void }) {
|
||||
const types: NodeType[] = [
|
||||
"start",
|
||||
"send_buttons",
|
||||
"send_list",
|
||||
"send_message",
|
||||
"send_media",
|
||||
"collect_input",
|
||||
"condition",
|
||||
"set_tag",
|
||||
"handoff",
|
||||
"end",
|
||||
'start',
|
||||
'send_buttons',
|
||||
'send_list',
|
||||
'send_message',
|
||||
'send_media',
|
||||
'collect_input',
|
||||
'condition',
|
||||
'set_tag',
|
||||
'handoff',
|
||||
'end',
|
||||
];
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-800"
|
||||
className="border-border bg-card text-foreground hover:bg-muted inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
|
||||
aria-label="Add node"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add node
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="border-slate-700 bg-slate-900">
|
||||
{types.map((t) => {
|
||||
<DropdownMenuContent align="end" className="border-border bg-popover">
|
||||
{groupNodeTypesByCategory(types).map((group, i) => (
|
||||
<div key={group.id}>
|
||||
{i > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel className="text-muted-foreground text-[11px] font-semibold tracking-wider uppercase">
|
||||
{group.label}
|
||||
</DropdownMenuLabel>
|
||||
{group.types.map((t) => {
|
||||
const meta = NODE_META[t];
|
||||
return (
|
||||
<DropdownMenuItem key={t} onClick={() => onAdd(t)}>
|
||||
<meta.icon className={cn("h-3.5 w-3.5", meta.color)} />
|
||||
<meta.icon className={cn('h-3.5 w-3.5', meta.color)} />
|
||||
{meta.label}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* Canvas / mind-map view of a flow. Editable, in parity with the
|
||||
@ -36,10 +36,11 @@
|
||||
* list view reads.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
applyNodeChanges,
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
Controls,
|
||||
Handle,
|
||||
MiniMap,
|
||||
@ -54,12 +55,12 @@ import {
|
||||
type NodeChange,
|
||||
type NodeProps,
|
||||
type OnNodeDrag,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
@ -67,27 +68,32 @@ import {
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
applyEdgeConnection,
|
||||
deriveCanvasEdges,
|
||||
outgoingSlots,
|
||||
} from "@/lib/flows/edges";
|
||||
import { autoLayout, shouldAutoLayout } from "@/lib/flows/layout";
|
||||
} from '@/lib/flows/edges';
|
||||
import { autoLayout, shouldAutoLayout } from '@/lib/flows/layout';
|
||||
import {
|
||||
NODE_META,
|
||||
NodeIconChip,
|
||||
groupNodeTypesByCategory,
|
||||
nodeColors,
|
||||
summarizeNode,
|
||||
type BuilderNode,
|
||||
type NodeType,
|
||||
} from "./shared";
|
||||
} from './shared';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useFlowEditor } from "./flow-editor-state";
|
||||
import { NodeConfigForm } from "./forms/node-config-form";
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useFlowEditor } from './flow-editor-state';
|
||||
import { NodeConfigForm } from './forms/node-config-form';
|
||||
|
||||
// React-Flow node `data` payload — the bits our custom renderer needs.
|
||||
interface NodeData extends Record<string, unknown> {
|
||||
@ -109,17 +115,32 @@ const NODE_HEIGHT = 90;
|
||||
// view's collapsed card so the two views feel like the same product.
|
||||
// ============================================================
|
||||
|
||||
// Echo the design's green/red branch ports for the condition node's
|
||||
// true / false slots; every other slot inherits the node's own hue.
|
||||
// The true/false hues are derived from the start (emerald) and handoff
|
||||
// (rose) node colors so all branch/port colors stay single-sourced in
|
||||
// NODE_HUE — a palette tweak there can't leave these stale.
|
||||
function slotColor(nodeType: NodeType, slotId: string, fallback: string) {
|
||||
if (nodeType === 'condition' && slotId === 'true') {
|
||||
return nodeColors('start').solid;
|
||||
}
|
||||
if (nodeType === 'condition' && slotId === 'false') {
|
||||
return nodeColors('handoff').solid;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
const { node, isEntry, isFlashed } = data as NodeData;
|
||||
const meta = NODE_META[node.node_type];
|
||||
const c = nodeColors(node.node_type);
|
||||
const summary = summarizeNode(node);
|
||||
const Icon = meta.icon;
|
||||
const slots = outgoingSlots(node);
|
||||
// Start nodes are entry-only; nothing ever targets them, so they
|
||||
// don't need an incoming Handle. Every other node type accepts
|
||||
// incoming edges (including terminal handoff / end — they're the
|
||||
// common targets).
|
||||
const hasTarget = node.node_type !== "start";
|
||||
const hasTarget = node.node_type !== 'start';
|
||||
// Single-slot nodes get a single source handle floated on the right
|
||||
// edge of the card. Multi-slot nodes (condition, send_buttons,
|
||||
// send_list) render slot rows inline so each handle visually sits
|
||||
@ -127,51 +148,71 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
const isMultiSlot = slots.length > 1;
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
'--nc': c.solid,
|
||||
'--nc-soft': c.soft,
|
||||
'--nc-ring': c.ring,
|
||||
'--nc-text': c.text,
|
||||
borderColor: selected ? c.solid : undefined,
|
||||
boxShadow: selected
|
||||
? `0 0 0 1px ${c.solid}, 0 14px 36px -12px ${c.ring}`
|
||||
: undefined,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"relative min-w-[220px] max-w-[260px] rounded-lg border bg-slate-900/95 px-3 py-2 text-left shadow-lg backdrop-blur transition-colors",
|
||||
'bg-card relative max-w-[260px] min-w-[220px] rounded-xl border px-3.5 py-3 text-left shadow-[0_2px_6px_rgba(0,0,0,0.18)] transition-[box-shadow,border-color]',
|
||||
selected
|
||||
? "border-primary ring-1 ring-primary/40"
|
||||
: "border-slate-700 hover:border-slate-600",
|
||||
? 'border-[var(--nc)]'
|
||||
: 'border-border hover:border-[var(--nc-ring)]',
|
||||
// Flash overrides hover/selected colors briefly. Tailwind's
|
||||
// built-in `animate-pulse` is too gentle; a ring with the
|
||||
// amber accent matches the list view's flash semantics.
|
||||
isFlashed && "!border-amber-400 ring-2 ring-amber-400/60",
|
||||
isFlashed && '!border-amber-400 ring-2 ring-amber-400/60'
|
||||
)}
|
||||
>
|
||||
{hasTarget && (
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-2.5 !w-2.5 !border-slate-600 !bg-slate-700"
|
||||
className="!bg-card !h-2.5 !w-2.5 !border-2 !border-[var(--nc-ring)]"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={cn("h-3.5 w-3.5 shrink-0", meta.color)} />
|
||||
<span className="truncate text-[11px] font-medium uppercase tracking-wide text-slate-400">
|
||||
<NodeIconChip
|
||||
type={node.node_type}
|
||||
size={24}
|
||||
iconSize={14}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<span
|
||||
className="truncate text-[10.5px] font-semibold tracking-wider uppercase"
|
||||
style={{ color: c.text }}
|
||||
>
|
||||
{meta.label}
|
||||
</span>
|
||||
{isEntry && (
|
||||
<span className="ml-auto rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-300">
|
||||
<span className="border-border text-muted-foreground ml-auto rounded border px-1.5 py-0.5 text-[8.5px] font-bold tracking-[0.1em] uppercase">
|
||||
Entry
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-slate-300">
|
||||
<div className="text-muted-foreground mt-2 truncate font-mono text-[11px]">
|
||||
{node.node_key}
|
||||
</div>
|
||||
{summary && (
|
||||
<div className="mt-1 line-clamp-2 text-xs text-slate-400">
|
||||
<div className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
|
||||
{summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isMultiSlot && (
|
||||
<div className="mt-2 flex flex-col gap-1 border-t border-slate-800 pt-2">
|
||||
<div className="border-border mt-2.5 flex flex-col gap-1 border-t pt-2.5">
|
||||
{slots.map((slot) => (
|
||||
<div
|
||||
key={slot.id}
|
||||
className="relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px] text-slate-300"
|
||||
className="text-muted-foreground relative flex items-center justify-between gap-2 rounded px-1 py-0.5 text-[11px]"
|
||||
>
|
||||
<span className="truncate" title={slot.label}>
|
||||
{slot.label}
|
||||
@ -180,11 +221,14 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
type="source"
|
||||
id={slot.id}
|
||||
position={Position.Right}
|
||||
style={{
|
||||
borderColor: slotColor(node.node_type, slot.id, c.solid),
|
||||
}}
|
||||
// Override default absolute positioning so the handle
|
||||
// sits flush with the right edge of the card instead
|
||||
// of floating at vertical center. The negative offset
|
||||
// matches the card's px-3 + the handle's own radius.
|
||||
className="!relative !right-auto !top-auto !h-2.5 !w-2.5 !translate-x-[12px] !transform-none !border-slate-600 !bg-slate-700"
|
||||
className="!bg-card !relative !top-auto !right-auto !h-2.5 !w-2.5 !translate-x-[14px] !transform-none !border-2"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
@ -196,7 +240,8 @@ function FlowNodeCard({ data, selected }: NodeProps) {
|
||||
type="source"
|
||||
id={slots[0].id}
|
||||
position={Position.Right}
|
||||
className="!h-2.5 !w-2.5 !border-slate-600 !bg-slate-700"
|
||||
style={{ borderColor: c.solid }}
|
||||
className="!bg-card !h-2.5 !w-2.5 !border-2"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -244,9 +289,9 @@ function FlowCanvasInner() {
|
||||
const selectedNode = useMemo(
|
||||
() =>
|
||||
selectedNodeKey
|
||||
? builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null
|
||||
? (builderNodes.find((n) => n.node_key === selectedNodeKey) ?? null)
|
||||
: null,
|
||||
[selectedNodeKey, builderNodes],
|
||||
[selectedNodeKey, builderNodes]
|
||||
);
|
||||
|
||||
const autoLayoutPositions = useMemo(() => {
|
||||
@ -260,7 +305,7 @@ function FlowCanvasInner() {
|
||||
height: NODE_HEIGHT,
|
||||
})),
|
||||
canvasEdges.map((e) => ({ source: e.source, target: e.target })),
|
||||
{ direction: "TB" },
|
||||
{ direction: 'TB' }
|
||||
)
|
||||
: null;
|
||||
}, [builderNodes]);
|
||||
@ -275,8 +320,8 @@ function FlowCanvasInner() {
|
||||
persistedAutoLayoutRef.current = true;
|
||||
updateNodePositions(
|
||||
Object.fromEntries(
|
||||
[...autoLayoutPositions].map(([key, pos]) => [key, pos]),
|
||||
),
|
||||
[...autoLayoutPositions].map(([key, pos]) => [key, pos])
|
||||
)
|
||||
);
|
||||
}, [autoLayoutPositions, updateNodePositions]);
|
||||
|
||||
@ -285,7 +330,7 @@ function FlowCanvasInner() {
|
||||
const fallback = autoLayoutPositions?.get(n.node_key);
|
||||
return {
|
||||
id: n.node_key,
|
||||
type: "flow",
|
||||
type: 'flow',
|
||||
position: {
|
||||
x: fallback?.x ?? n.position_x ?? 0,
|
||||
y: fallback?.y ?? n.position_y ?? 0,
|
||||
@ -319,11 +364,12 @@ function FlowCanvasInner() {
|
||||
target: e.target,
|
||||
sourceHandle: e.sourceHandle,
|
||||
label: e.label,
|
||||
labelStyle: { fill: "#cbd5e1", fontSize: 11 },
|
||||
labelBgStyle: { fill: "#0f172a" },
|
||||
// Mode-aware via CSS tokens so edge chrome flips with light/dark.
|
||||
labelStyle: { fill: 'var(--muted-foreground)', fontSize: 11 },
|
||||
labelBgStyle: { fill: 'var(--card)' },
|
||||
labelBgPadding: [4, 2] as [number, number],
|
||||
labelBgBorderRadius: 4,
|
||||
style: { stroke: "#475569", strokeWidth: 1.5 },
|
||||
style: { stroke: 'var(--border)', strokeWidth: 1.5 },
|
||||
}));
|
||||
|
||||
return rfEdges;
|
||||
@ -333,7 +379,7 @@ function FlowCanvasInner() {
|
||||
(changes: NodeChange<RfNode<NodeData>>[]) => {
|
||||
setRfNodes((nodes) => applyNodeChanges(changes, nodes));
|
||||
},
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
// Drag-to-position: React-Flow tracks the visual drag internally and
|
||||
@ -347,7 +393,7 @@ function FlowCanvasInner() {
|
||||
(_event, node) => {
|
||||
updateNodePosition(node.id, node.position.x, node.position.y);
|
||||
},
|
||||
[updateNodePosition],
|
||||
[updateNodePosition]
|
||||
);
|
||||
|
||||
// Pan to the flashed node when the validator panel requests one.
|
||||
@ -369,7 +415,7 @@ function FlowCanvasInner() {
|
||||
(_event: React.MouseEvent, node: RfNode<NodeData>) => {
|
||||
setSelectedNodeKey(node.id);
|
||||
},
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
// Drag-to-connect: React-Flow fires onConnect when the user drops a
|
||||
@ -380,11 +426,15 @@ function FlowCanvasInner() {
|
||||
// the next render — no need to maintain a separate edge list.
|
||||
const handleConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!connection.source || !connection.target || !connection.sourceHandle) {
|
||||
if (
|
||||
!connection.source ||
|
||||
!connection.target ||
|
||||
!connection.sourceHandle
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const sourceNode = builderNodes.find(
|
||||
(n) => n.node_key === connection.source,
|
||||
(n) => n.node_key === connection.source
|
||||
);
|
||||
if (!sourceNode) return;
|
||||
// Self-loops are a footgun (a button whose target is its own
|
||||
@ -394,11 +444,11 @@ function FlowCanvasInner() {
|
||||
const patch = applyEdgeConnection(
|
||||
sourceNode,
|
||||
connection.sourceHandle,
|
||||
connection.target,
|
||||
connection.target
|
||||
);
|
||||
if (patch) updateNodeConfig(connection.source, patch);
|
||||
},
|
||||
[builderNodes, updateNodeConfig],
|
||||
[builderNodes, updateNodeConfig]
|
||||
);
|
||||
|
||||
// Keyboard delete (Backspace / Delete) + drag-to-trash. React-Flow
|
||||
@ -414,7 +464,7 @@ function FlowCanvasInner() {
|
||||
if (selectedNodeKey === n.id) setSelectedNodeKey(null);
|
||||
}
|
||||
},
|
||||
[removeNode, selectedNodeKey],
|
||||
[removeNode, selectedNodeKey]
|
||||
);
|
||||
|
||||
// Edge delete: clear the source node's slot rather than removing
|
||||
@ -426,11 +476,11 @@ function FlowCanvasInner() {
|
||||
if (!e.sourceHandle) continue;
|
||||
const sourceNode = builderNodes.find((n) => n.node_key === e.source);
|
||||
if (!sourceNode) continue;
|
||||
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, "");
|
||||
const patch = applyEdgeConnection(sourceNode, e.sourceHandle, '');
|
||||
if (patch) updateNodeConfig(e.source, patch);
|
||||
}
|
||||
},
|
||||
[builderNodes, updateNodeConfig],
|
||||
[builderNodes, updateNodeConfig]
|
||||
);
|
||||
|
||||
// Wrapped mutators that target the currently-selected node — pass to
|
||||
@ -440,7 +490,7 @@ function FlowCanvasInner() {
|
||||
(patch: Record<string, unknown>) => {
|
||||
if (selectedNodeKey) updateNodeConfig(selectedNodeKey, patch);
|
||||
},
|
||||
[selectedNodeKey, updateNodeConfig],
|
||||
[selectedNodeKey, updateNodeConfig]
|
||||
);
|
||||
|
||||
const handleDeleteSelected = useCallback(() => {
|
||||
@ -456,7 +506,7 @@ function FlowCanvasInner() {
|
||||
|
||||
if (rfNodes.length === 0) {
|
||||
return (
|
||||
<div className="flex h-[60vh] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-slate-700 bg-slate-950 text-sm text-slate-500">
|
||||
<div className="text-muted-foreground flex h-full flex-col items-center justify-center gap-3 text-sm">
|
||||
<p>No nodes yet.</p>
|
||||
<CanvasAddNodeButton />
|
||||
</div>
|
||||
@ -465,7 +515,7 @@ function FlowCanvasInner() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-[70vh] w-full overflow-hidden rounded-lg border border-slate-800 bg-slate-950">
|
||||
<div className="h-full w-full overflow-hidden">
|
||||
<ReactFlow
|
||||
nodes={rfNodes}
|
||||
edges={rfEdges}
|
||||
@ -481,7 +531,7 @@ function FlowCanvasInner() {
|
||||
onEdgesDelete={handleEdgesDelete}
|
||||
// Default is "Backspace" only — accept both so Mac users
|
||||
// hitting Delete (Fn+Backspace) get the same behavior.
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
nodesConnectable={true}
|
||||
edgesFocusable={true}
|
||||
elementsSelectable={true}
|
||||
@ -491,19 +541,29 @@ function FlowCanvasInner() {
|
||||
minZoom={0.2}
|
||||
maxZoom={1.5}
|
||||
>
|
||||
<Background gap={24} size={1} color="#1e293b" />
|
||||
{/* Dot grid, matching the design's faint canvas backdrop. */}
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={22}
|
||||
size={1.4}
|
||||
color="var(--border)"
|
||||
/>
|
||||
<Controls
|
||||
className="!border-slate-700 !bg-slate-900 [&_button]:!border-slate-700 [&_button]:!bg-slate-900 [&_button:hover]:!bg-slate-800"
|
||||
className="!border-border !bg-card [&_button]:!border-border [&_button]:!bg-card [&_button:hover]:!bg-muted [&_button_svg]:!fill-foreground !overflow-hidden !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
|
||||
showInteractive={false}
|
||||
/>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeColor="#334155"
|
||||
maskColor="rgba(15, 23, 42, 0.7)"
|
||||
className="!border !border-slate-700 !bg-slate-900"
|
||||
nodeColor={(n) =>
|
||||
nodeColors((n.data as NodeData).node.node_type).solid
|
||||
}
|
||||
nodeStrokeWidth={0}
|
||||
nodeBorderRadius={3}
|
||||
maskColor="color-mix(in oklch, var(--background) 70%, transparent)"
|
||||
className="!border-border !bg-card !rounded-xl !border !shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)]"
|
||||
/>
|
||||
<Panel position="bottom-right" className="!bottom-4 !right-4">
|
||||
<Panel position="top-left" className="!top-4 !left-4">
|
||||
<CanvasAddNodeButton />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
@ -556,26 +616,31 @@ function NodeEditSheet({
|
||||
);
|
||||
}
|
||||
const meta = NODE_META[node.node_type];
|
||||
const Icon = meta.icon;
|
||||
const c = nodeColors(node.node_type);
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="flex w-full flex-col gap-0 border-l border-slate-800 bg-slate-950 p-0 sm:max-w-md"
|
||||
className="border-border bg-popover flex w-full flex-col gap-0 border-l p-0 sm:max-w-md"
|
||||
>
|
||||
<SheetHeader className="border-b border-slate-800 px-5 py-4">
|
||||
<SheetTitle className="flex items-center gap-2 text-slate-100">
|
||||
<Icon className={cn("h-4 w-4 shrink-0", meta.color)} />
|
||||
<span>{meta.label}</span>
|
||||
<SheetHeader className="border-border flex-row items-center gap-3 space-y-0 border-b px-5 py-4">
|
||||
<NodeIconChip type={node.node_type} size={36} iconSize={18} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<SheetTitle className="flex items-center gap-2 text-[11px] font-semibold tracking-wider uppercase">
|
||||
<span style={{ color: c.text }}>{meta.label}</span>
|
||||
{isEntry && (
|
||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-emerald-300">
|
||||
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold tracking-wider text-emerald-300 uppercase">
|
||||
Entry
|
||||
</span>
|
||||
)}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="font-mono text-[11px] text-slate-400">
|
||||
{node.node_key}
|
||||
<SheetDescription className="text-muted-foreground mt-0.5 text-xs">
|
||||
{meta.blurb}
|
||||
</SheetDescription>
|
||||
</div>
|
||||
<code className="bg-muted text-muted-foreground shrink-0 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
||||
{node.node_key}
|
||||
</code>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-3 overflow-y-auto px-5 py-4">
|
||||
@ -587,7 +652,7 @@ function NodeEditSheet({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SheetFooter className="border-t border-slate-800 px-5 py-3 sm:flex-row sm:justify-between">
|
||||
<SheetFooter className="border-border border-t px-5 py-3 sm:flex-row sm:justify-between">
|
||||
{!isEntry ? (
|
||||
<Button variant="ghost" size="sm" onClick={onSetEntry}>
|
||||
Set as entry
|
||||
@ -618,16 +683,16 @@ function NodeEditSheet({
|
||||
// ============================================================
|
||||
|
||||
const ADD_NODE_TYPES: NodeType[] = [
|
||||
"start",
|
||||
"send_buttons",
|
||||
"send_list",
|
||||
"send_message",
|
||||
"send_media",
|
||||
"collect_input",
|
||||
"condition",
|
||||
"set_tag",
|
||||
"handoff",
|
||||
"end",
|
||||
'start',
|
||||
'send_buttons',
|
||||
'send_list',
|
||||
'send_message',
|
||||
'send_media',
|
||||
'collect_input',
|
||||
'condition',
|
||||
'set_tag',
|
||||
'handoff',
|
||||
'end',
|
||||
];
|
||||
|
||||
function CanvasAddNodeButton() {
|
||||
@ -641,7 +706,7 @@ function CanvasAddNodeButton() {
|
||||
// .react-flow root and read its bounding rect. If we can't find
|
||||
// it (test envs, etc.), addNode's default (0, 0) is the fallback
|
||||
// and the user can drag the node into view.
|
||||
const root = document.querySelector(".react-flow") as HTMLElement | null;
|
||||
const root = document.querySelector('.react-flow') as HTMLElement | null;
|
||||
if (!root) return;
|
||||
const rect = root.getBoundingClientRect();
|
||||
const center = reactFlow.screenToFlowPosition({
|
||||
@ -651,29 +716,59 @@ function CanvasAddNodeButton() {
|
||||
// NODE_WIDTH / NODE_HEIGHT are the dagre layout defaults; offset
|
||||
// so the card sits visually centered rather than top-left at the
|
||||
// viewport center.
|
||||
updateNodePosition(key, center.x - NODE_WIDTH / 2, center.y - NODE_HEIGHT / 2);
|
||||
updateNodePosition(
|
||||
key,
|
||||
center.x - NODE_WIDTH / 2,
|
||||
center.y - NODE_HEIGHT / 2
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-900 px-3 py-1.5 text-xs font-medium text-slate-200 shadow-lg transition-colors hover:bg-slate-800"
|
||||
className="bg-primary text-primary-foreground hover:bg-primary-hover inline-flex items-center gap-1.5 rounded-lg px-3.5 py-2 text-[13px] font-medium shadow-[0_6px_20px_-8px_rgba(0,0,0,0.5)] transition-colors"
|
||||
aria-label="Add node"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
<Plus className="h-4 w-4" />
|
||||
Add node
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="border-slate-700 bg-slate-900">
|
||||
{ADD_NODE_TYPES.map((t) => {
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="border-border bg-popover w-[268px] p-1.5"
|
||||
>
|
||||
{groupNodeTypesByCategory(ADD_NODE_TYPES).map((group, i) => (
|
||||
<div key={group.id}>
|
||||
{i > 0 && <DropdownMenuSeparator />}
|
||||
<DropdownMenuLabel className="text-muted-foreground px-2 py-1.5 text-[11px] font-semibold tracking-wider uppercase">
|
||||
{group.label}
|
||||
</DropdownMenuLabel>
|
||||
{group.types.map((t) => {
|
||||
const meta = NODE_META[t];
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<DropdownMenuItem key={t} onClick={() => handleAdd(t)}>
|
||||
<Icon className={cn("h-3.5 w-3.5", meta.color)} />
|
||||
<DropdownMenuItem
|
||||
key={t}
|
||||
onClick={() => handleAdd(t)}
|
||||
className="gap-3 py-2"
|
||||
>
|
||||
<NodeIconChip
|
||||
type={t}
|
||||
size={28}
|
||||
iconSize={16}
|
||||
className="rounded-md"
|
||||
/>
|
||||
<span className="flex flex-col">
|
||||
<span className="text-popover-foreground text-[13px] font-semibold">
|
||||
{meta.label}
|
||||
</span>
|
||||
<span className="text-muted-foreground text-[11.5px]">
|
||||
{meta.blurb}
|
||||
</span>
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
|
||||
@ -1,11 +1,19 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* View-switcher for the flow editor.
|
||||
* View-switcher + chrome for the flow editor.
|
||||
*
|
||||
* Renders a small Canvas / List pill above whichever view is active,
|
||||
* and conditionally mounts `<FlowCanvas>` or `<FlowBuilder>`. Why a
|
||||
* separate component:
|
||||
* Lays the editor out as one app-like column that fills the dashboard
|
||||
* content area (toolbar → mode row → stage → validation bar), matching
|
||||
* the Flow Builder design handoff:
|
||||
* - A segmented Canvas / List control on the left of the mode row.
|
||||
* - A node-type legend on the right so the canvas's per-type colors
|
||||
* are decodable at a glance.
|
||||
* - The active view is mounted inside a rounded "stage" that owns its
|
||||
* own scroll/overflow, so the canvas can fill available height and
|
||||
* the list scrolls internally.
|
||||
*
|
||||
* Why a separate component:
|
||||
* - The page itself stays trivially small (loading + error + this).
|
||||
* - Either view can stay unaware of the other — they share data
|
||||
* (`{flow, nodes}`) and nothing else.
|
||||
@ -17,13 +25,14 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { LayoutGrid, ListTree } from "lucide-react";
|
||||
import { GitFork, List } from "lucide-react";
|
||||
|
||||
import { FlowBuilder } from "./flow-builder";
|
||||
import { FlowCanvas } from "./flow-canvas";
|
||||
import { FlowEditorProvider } from "./flow-editor-state";
|
||||
import { EditorHeader } from "./header";
|
||||
import { ValidationPanel } from "./validation-panel";
|
||||
import { NODE_META, nodeColors, type NodeType } from "./shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FlowRow, FlowNodeRow } from "@/lib/flows/types";
|
||||
|
||||
@ -39,6 +48,11 @@ type View = "canvas" | "list";
|
||||
|
||||
const STORAGE_KEY = "wacrm.flowEditor.view";
|
||||
|
||||
// Legend covers every node type, derived from NODE_META so a new type
|
||||
// can't silently go undocumented. NODE_META's key order already reads
|
||||
// the way a flow flows: start → talk → capture → branch → mutate → end.
|
||||
const LEGEND_TYPES = Object.keys(NODE_META) as NodeType[];
|
||||
|
||||
interface Props {
|
||||
initialFlow: FlowRow;
|
||||
initialNodes: FlowNodeRow[];
|
||||
@ -78,37 +92,63 @@ export function FlowEditorShell({ initialFlow, initialNodes }: Props) {
|
||||
|
||||
return (
|
||||
<FlowEditorProvider initialFlow={initialFlow} initialNodes={initialNodes}>
|
||||
<div className="mx-auto flex h-full max-w-4xl flex-col gap-6 p-6">
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<EditorHeader />
|
||||
|
||||
{/* ---- mode row: view toggle + node-type legend ----
|
||||
Omitted entirely on mobile (canvas is unavailable there and
|
||||
the legend is lg-only), so there's no empty band above the
|
||||
stage on small screens. */}
|
||||
{!isMobile && (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex items-center gap-4 px-6 py-3.5">
|
||||
<div
|
||||
role="group"
|
||||
aria-label="Editor view"
|
||||
className="inline-flex items-center gap-1 rounded-md border border-slate-700 bg-slate-900 p-0.5 text-xs"
|
||||
className="inline-flex gap-0.5 rounded-lg border border-border bg-muted p-0.5"
|
||||
>
|
||||
<ToggleButton
|
||||
<SegButton
|
||||
active={effectiveView === "canvas"}
|
||||
onClick={() => choose("canvas")}
|
||||
icon={<LayoutGrid className="h-3 w-3" />}
|
||||
icon={<GitFork className="h-3.5 w-3.5" />}
|
||||
label="Canvas"
|
||||
/>
|
||||
<ToggleButton
|
||||
<SegButton
|
||||
active={effectiveView === "list"}
|
||||
onClick={() => choose("list")}
|
||||
icon={<ListTree className="h-3 w-3" />}
|
||||
icon={<List className="h-3.5 w-3.5" />}
|
||||
label="List"
|
||||
/>
|
||||
</div>
|
||||
<div className="ml-auto hidden flex-wrap items-center gap-x-3.5 gap-y-1.5 lg:flex">
|
||||
{LEGEND_TYPES.map((t) => (
|
||||
<span
|
||||
key={t}
|
||||
className="inline-flex items-center gap-1.5 text-[11.5px] text-muted-foreground"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 rounded-full"
|
||||
style={{ background: nodeColors(t).solid }}
|
||||
/>
|
||||
{NODE_META[t].label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{effectiveView === "canvas" ? <FlowCanvas /> : <FlowBuilder />}
|
||||
{/* ---- stage: the active view, owning its own overflow ---- */}
|
||||
<div className="relative mx-6 min-h-0 flex-1 overflow-hidden rounded-xl border border-border bg-card-2">
|
||||
{effectiveView === "canvas" ? (
|
||||
<FlowCanvas />
|
||||
) : (
|
||||
<div className="absolute inset-0 overflow-y-auto">
|
||||
<FlowBuilder />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sticky-bottom validation panel mirrors the placement used
|
||||
when this lived inside FlowBuilder — the activate-readiness
|
||||
status follows the user as they scroll, in either view. */}
|
||||
<div className="sticky bottom-4 z-10 shadow-xl shadow-slate-950/60">
|
||||
{/* ---- validation / activate-readiness bar ---- */}
|
||||
<div className="px-6 pb-5 pt-3">
|
||||
<ValidationPanel />
|
||||
</div>
|
||||
</div>
|
||||
@ -138,7 +178,7 @@ function useMatchMedia(query: string): boolean {
|
||||
return matches;
|
||||
}
|
||||
|
||||
function ToggleButton({
|
||||
function SegButton({
|
||||
active,
|
||||
onClick,
|
||||
icon,
|
||||
@ -155,10 +195,10 @@ function ToggleButton({
|
||||
onClick={onClick}
|
||||
aria-pressed={active}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded px-2 py-1 transition-colors",
|
||||
"inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[12.5px] font-medium transition-colors",
|
||||
active
|
||||
? "bg-slate-700 text-slate-100"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-slate-200",
|
||||
? "bg-card text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
|
||||
@ -43,19 +43,19 @@ export function TextRow({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">{label}</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{label}</label>
|
||||
{rows > 1 ? (
|
||||
<Textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
className="bg-slate-800"
|
||||
className="bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="bg-slate-800"
|
||||
className="bg-muted"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -77,7 +77,7 @@ export function NextNodeRow({
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">{label}</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">{label}</label>
|
||||
<NodeKeySelect
|
||||
value={value || null}
|
||||
nodes={allNodes}
|
||||
@ -110,7 +110,7 @@ export function NodeKeySelect({
|
||||
value={value ?? "__none__"}
|
||||
onValueChange={(v) => onChange(v === "__none__" ? null : v)}
|
||||
>
|
||||
<SelectTrigger className={cn("bg-slate-800", className)}>
|
||||
<SelectTrigger className={cn("bg-muted", className)}>
|
||||
<SelectValue placeholder={placeholder ?? "—"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@ -45,7 +45,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { uploadAccountMedia, MEDIA_MAX_BYTES } from "@/lib/storage/upload-media";
|
||||
import { slugify, type BuilderNode } from "../shared";
|
||||
import { NextNodeRow, NodeKeySelect, TextRow } from "./fields";
|
||||
|
||||
@ -135,7 +135,7 @@ export function NodeConfigForm({
|
||||
rows={2}
|
||||
/>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
Variable key (stored in flow_runs.vars; alphanumeric + underscore)
|
||||
</label>
|
||||
<Input
|
||||
@ -146,11 +146,11 @@ export function NodeConfigForm({
|
||||
})
|
||||
}
|
||||
placeholder="e.g. name, email, company"
|
||||
className="bg-slate-800 font-mono text-xs"
|
||||
className="bg-muted font-mono text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-slate-500">
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
Interpolate in downstream prompts and handoff notes with{" "}
|
||||
<code className="rounded bg-slate-800 px-1">
|
||||
<code className="rounded bg-muted px-1">
|
||||
{"{{vars."}
|
||||
{(cfg as { var_key?: string }).var_key || "name"}
|
||||
{"}}"}
|
||||
@ -200,7 +200,7 @@ export function NodeConfigForm({
|
||||
|
||||
case "end":
|
||||
return (
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Terminal node. When the runner reaches this node the run is marked
|
||||
complete. No config needed.
|
||||
</p>
|
||||
@ -269,7 +269,7 @@ function SendButtonsForm({
|
||||
/>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<label className="text-xs text-slate-400">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
Buttons (1–3) — each one routes to a different next node
|
||||
</label>
|
||||
</div>
|
||||
@ -278,7 +278,7 @@ function SendButtonsForm({
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-2 rounded-md border border-slate-800 bg-slate-800/40 p-3",
|
||||
"grid grid-cols-1 gap-2 rounded-md border border-border bg-muted/40 p-3",
|
||||
showAdvanced
|
||||
? "md:grid-cols-[1fr_2fr_2fr_auto]"
|
||||
: "md:grid-cols-[2fr_2fr_auto]",
|
||||
@ -293,14 +293,14 @@ function SendButtonsForm({
|
||||
})
|
||||
}
|
||||
placeholder="reply_id"
|
||||
className="bg-slate-800 font-mono text-xs"
|
||||
className="bg-muted font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
value={b.title}
|
||||
onChange={(e) => updateButton(i, { title: e.target.value })}
|
||||
placeholder="Visible title (≤20 chars)"
|
||||
className="bg-slate-800"
|
||||
className="bg-muted"
|
||||
maxLength={20}
|
||||
/>
|
||||
<NodeKeySelect
|
||||
@ -465,13 +465,13 @@ function SendListForm({
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<label className="mb-2 block text-xs text-slate-400">
|
||||
<label className="mb-2 block text-xs text-muted-foreground">
|
||||
Rows (1–10 total across all sections)
|
||||
</label>
|
||||
{sections.map((section, sIdx) => (
|
||||
<div
|
||||
key={sIdx}
|
||||
className="mb-3 rounded-md border border-slate-800 bg-slate-800/40 p-3"
|
||||
className="mb-3 rounded-md border border-border bg-muted/40 p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Input
|
||||
@ -480,7 +480,7 @@ function SendListForm({
|
||||
updateSection(sIdx, { title: e.target.value })
|
||||
}
|
||||
placeholder={`Section ${sIdx + 1} title (optional)`}
|
||||
className="bg-slate-800 text-xs"
|
||||
className="bg-muted text-xs"
|
||||
/>
|
||||
{sections.length > 1 && (
|
||||
<Button
|
||||
@ -516,7 +516,7 @@ function SendListForm({
|
||||
})
|
||||
}
|
||||
placeholder="reply_id"
|
||||
className="bg-slate-800 font-mono text-xs"
|
||||
className="bg-muted font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
@ -525,7 +525,7 @@ function SendListForm({
|
||||
updateRow(sIdx, rIdx, { title: e.target.value })
|
||||
}
|
||||
placeholder="Row title (≤24)"
|
||||
className="bg-slate-800"
|
||||
className="bg-muted"
|
||||
maxLength={24}
|
||||
/>
|
||||
<NodeKeySelect
|
||||
@ -614,14 +614,14 @@ function ConditionForm({
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">If</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">If</label>
|
||||
<Select
|
||||
value={subject}
|
||||
onValueChange={(v) =>
|
||||
onUpdateConfig({ subject: v as ConditionCfg["subject"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-800">
|
||||
<SelectTrigger className="bg-muted">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -632,7 +632,7 @@ function ConditionForm({
|
||||
</Select>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<label className="mb-1 block text-xs text-slate-400">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
{subject === "var"
|
||||
? "var name"
|
||||
: subject === "tag"
|
||||
@ -644,7 +644,7 @@ function ConditionForm({
|
||||
value={cfg.subject_key ?? ""}
|
||||
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-800">
|
||||
<SelectTrigger className="bg-muted">
|
||||
<SelectValue placeholder="Pick a tag…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -660,7 +660,7 @@ function ConditionForm({
|
||||
value={cfg.subject_key ?? ""}
|
||||
onValueChange={(v) => onUpdateConfig({ subject_key: v })}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-800">
|
||||
<SelectTrigger className="bg-muted">
|
||||
<SelectValue placeholder="Pick a field…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -677,7 +677,7 @@ function ConditionForm({
|
||||
onUpdateConfig({ subject_key: e.target.value })
|
||||
}
|
||||
placeholder={subject === "var" ? "e.g. email" : "tag UUID"}
|
||||
className="bg-slate-800 font-mono text-xs"
|
||||
className="bg-muted font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -690,14 +690,14 @@ function ConditionForm({
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">Operator</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Operator</label>
|
||||
<Select
|
||||
value={operator}
|
||||
onValueChange={(v) =>
|
||||
onUpdateConfig({ operator: v as ConditionCfg["operator"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-800">
|
||||
<SelectTrigger className="bg-muted">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -710,11 +710,11 @@ function ConditionForm({
|
||||
</div>
|
||||
{showValue && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">Value</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Value</label>
|
||||
<Input
|
||||
value={cfg.value ?? ""}
|
||||
onChange={(e) => onUpdateConfig({ value: e.target.value })}
|
||||
className="bg-slate-800"
|
||||
className="bg-muted"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@ -767,14 +767,14 @@ function SetTagForm({
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">Action</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Action</label>
|
||||
<Select
|
||||
value={cfg.mode ?? "add"}
|
||||
onValueChange={(v) =>
|
||||
onUpdateConfig({ mode: v as SetTagCfg["mode"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-800">
|
||||
<SelectTrigger className="bg-muted">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -784,13 +784,13 @@ function SetTagForm({
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">Tag</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Tag</label>
|
||||
{tags.length > 0 ? (
|
||||
<Select
|
||||
value={cfg.tag_id ?? ""}
|
||||
onValueChange={(v) => onUpdateConfig({ tag_id: v })}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-800">
|
||||
<SelectTrigger className="bg-muted">
|
||||
<SelectValue placeholder="Pick a tag…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -806,7 +806,7 @@ function SetTagForm({
|
||||
value={cfg.tag_id ?? ""}
|
||||
onChange={(e) => onUpdateConfig({ tag_id: e.target.value })}
|
||||
placeholder="Tag UUID"
|
||||
className="bg-slate-800 font-mono text-xs"
|
||||
className="bg-muted font-mono text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -872,8 +872,6 @@ const MEDIA_ACCEPT: Record<NonNullable<SendMediaCfg["media_type"]>, string> = {
|
||||
};
|
||||
|
||||
const FLOW_MEDIA_BUCKET = "flow-media";
|
||||
// 16 MB — matches the bucket file_size_limit set in migration 016.
|
||||
const FLOW_MEDIA_MAX_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function SendMediaForm({
|
||||
cfg,
|
||||
@ -897,7 +895,7 @@ function SendMediaForm({
|
||||
|
||||
const handleFile = useCallback(
|
||||
async (file: File) => {
|
||||
if (file.size > FLOW_MEDIA_MAX_BYTES) {
|
||||
if (file.size > MEDIA_MAX_BYTES) {
|
||||
toast.error(
|
||||
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — limit is 16 MB.`,
|
||||
);
|
||||
@ -905,49 +903,9 @@ function SendMediaForm({
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const {
|
||||
data: { user },
|
||||
error: userErr,
|
||||
} = await supabase.auth.getUser();
|
||||
if (userErr || !user) {
|
||||
throw new Error("Not signed in.");
|
||||
}
|
||||
// Resolve the caller's account_id so the path is account-
|
||||
// scoped rather than user-scoped. The 020 migration's RLS
|
||||
// policy allows any account member to write under
|
||||
// `account-<account_id>/...`, so a flow's media survives a
|
||||
// teammate leaving the account (which was a data-integrity
|
||||
// hole in the original 016 storage policies).
|
||||
const { data: profile, error: profileErr } = await supabase
|
||||
.from("profiles")
|
||||
.select("account_id")
|
||||
.eq("user_id", user.id)
|
||||
.maybeSingle();
|
||||
if (profileErr || !profile?.account_id) {
|
||||
throw new Error("Could not resolve your account.");
|
||||
}
|
||||
// Path convention matches migration 020's RLS policy: first
|
||||
// segment is `account-<uuid>`. Timestamp + random suffix
|
||||
// prevents collisions between two builders open at once.
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "bin";
|
||||
const safeBase =
|
||||
file.name
|
||||
.replace(/\.[^.]+$/, "")
|
||||
.replace(/[^a-zA-Z0-9_-]+/g, "_")
|
||||
.slice(0, 40) || "file";
|
||||
const path = `account-${profile.account_id}/${Date.now()}-${safeBase}.${ext}`;
|
||||
const { error: upErr } = await supabase.storage
|
||||
.from(FLOW_MEDIA_BUCKET)
|
||||
.upload(path, file, {
|
||||
cacheControl: "3600",
|
||||
upsert: false,
|
||||
contentType: file.type,
|
||||
});
|
||||
if (upErr) throw new Error(upErr.message);
|
||||
const {
|
||||
data: { publicUrl },
|
||||
} = supabase.storage.from(FLOW_MEDIA_BUCKET).getPublicUrl(path);
|
||||
// Account-scoped upload (path `account-<id>/...`) — see
|
||||
// uploadAccountMedia + migration 020's flow-media RLS policy.
|
||||
const { publicUrl } = await uploadAccountMedia(FLOW_MEDIA_BUCKET, file);
|
||||
// Patch all fields in one call so the form doesn't re-render
|
||||
// with a half-uploaded state.
|
||||
onUpdateConfig({
|
||||
@ -972,7 +930,7 @@ function SendMediaForm({
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">Media type</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">Media type</label>
|
||||
<Select
|
||||
value={mediaType}
|
||||
onValueChange={(v) => {
|
||||
@ -986,7 +944,7 @@ function SendMediaForm({
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="bg-slate-800">
|
||||
<SelectTrigger className="bg-muted">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -1000,15 +958,15 @@ function SendMediaForm({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">File</label>
|
||||
<label className="mb-1 block text-xs text-muted-foreground">File</label>
|
||||
{cfg.media_url ? (
|
||||
<div className="flex items-center gap-2 rounded-md border border-slate-700 bg-slate-800 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-2 rounded-md border border-border bg-muted px-3 py-2 text-xs">
|
||||
<Paperclip className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
|
||||
<a
|
||||
href={cfg.media_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="min-w-0 flex-1 truncate text-slate-200 hover:text-cyan-300"
|
||||
className="min-w-0 flex-1 truncate text-foreground hover:text-cyan-300"
|
||||
title={displayName || cfg.media_url}
|
||||
>
|
||||
{displayName || cfg.media_url}
|
||||
@ -1016,7 +974,7 @@ function SendMediaForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="rounded p-1 text-slate-400 hover:bg-slate-700 hover:text-slate-200"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Remove file"
|
||||
disabled={uploading}
|
||||
>
|
||||
@ -1028,7 +986,7 @@ function SendMediaForm({
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed border-slate-700 bg-slate-900 px-3 py-4 text-xs text-slate-400 transition-colors hover:border-slate-600 hover:bg-slate-800 hover:text-slate-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-md border border-dashed border-border bg-card px-3 py-4 text-xs text-muted-foreground transition-colors hover:border-border hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
@ -1066,14 +1024,14 @@ function SendMediaForm({
|
||||
|
||||
{isDocument && (
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-slate-400">
|
||||
<label className="mb-1 block text-xs text-muted-foreground">
|
||||
Filename shown to the customer (documents only)
|
||||
</label>
|
||||
<Input
|
||||
value={cfg.filename ?? ""}
|
||||
onChange={(e) => onUpdateConfig({ filename: e.target.value })}
|
||||
placeholder="invoice.pdf"
|
||||
className="bg-slate-800 text-xs"
|
||||
className="bg-muted text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Editor header — flow name / description, status badge, dirty
|
||||
* Editor toolbar — flow name / description, status chip, dirty
|
||||
* indicator, and the action buttons (Save, Activate/Pause, Delete,
|
||||
* View runs, Back).
|
||||
*
|
||||
* Lifted out of flow-builder.tsx so the same header renders above
|
||||
* Restyled to the Flow Builder design handoff: a single compact
|
||||
* toolbar row (back · icon · inline-editable name · status chip ·
|
||||
* edited dot on the left; Runs · Delete · Activate · Save on the
|
||||
* right) followed by a subtle, full-width description "note" line.
|
||||
* Replaces the old three-row stack so the editor reads as one app
|
||||
* chrome bar above the canvas/list stage.
|
||||
*
|
||||
* Lifted out of flow-builder.tsx so the same toolbar renders above
|
||||
* both views in FlowEditorShell. Without this, canvas users had no
|
||||
* way to save without toggling to list view.
|
||||
*
|
||||
@ -18,6 +25,7 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
ArrowLeft,
|
||||
CircleDot,
|
||||
History,
|
||||
Loader2,
|
||||
PauseCircle,
|
||||
@ -27,9 +35,7 @@ import {
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
useFlowEditor,
|
||||
@ -52,32 +58,33 @@ export function EditorHeader() {
|
||||
} = useFlowEditor();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||
<div className="flex flex-col gap-1.5 px-6 pt-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* ---- left: back · icon · name · status · edited ---- */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push("/flows")}
|
||||
className="inline-flex items-center gap-1 hover:text-slate-300"
|
||||
title="Back to Flows"
|
||||
aria-label="Back to Flows"
|
||||
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" />
|
||||
Flows
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<Workflow className="h-5 w-5 shrink-0 text-primary" />
|
||||
<Input
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary-soft text-primary">
|
||||
<Workflow className="h-[18px] w-[18px]" />
|
||||
</span>
|
||||
<input
|
||||
value={state.name}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, name: e.target.value }))
|
||||
}
|
||||
onChange={(e) => setState((s) => ({ ...s, name: e.target.value }))}
|
||||
placeholder="Flow name"
|
||||
className="max-w-md bg-slate-900 text-lg font-semibold"
|
||||
spellCheck={false}
|
||||
aria-label="Flow name"
|
||||
className="min-w-[120px] max-w-[340px] rounded-lg border border-transparent bg-transparent px-2 py-1 text-lg font-bold leading-tight tracking-tight text-foreground outline-none transition-colors hover:bg-muted focus:border-primary focus:bg-transparent focus:shadow-[0_0_0_3px_var(--primary-soft)]"
|
||||
/>
|
||||
<StatusBadge status={state.status} />
|
||||
<StatusChip status={state.status} />
|
||||
{dirty && (
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-amber-300"
|
||||
className="inline-flex shrink-0 items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
|
||||
title="Unsaved changes — hit Save to persist"
|
||||
aria-live="polite"
|
||||
>
|
||||
@ -85,8 +92,9 @@ export function EditorHeader() {
|
||||
Edited
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
|
||||
{/* ---- right: runs · delete · activate · save ---- */}
|
||||
<div className="ml-auto flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@ -94,6 +102,9 @@ export function EditorHeader() {
|
||||
>
|
||||
<History className="h-3.5 w-3.5" />
|
||||
Runs
|
||||
<span className="ml-0.5 rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-muted-foreground">
|
||||
{flow.execution_count}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -148,27 +159,47 @@ export function EditorHeader() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
|
||||
{/* ---- description note (subtle, inline-editable) ---- */}
|
||||
<input
|
||||
value={state.description}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, description: e.target.value }))
|
||||
}
|
||||
placeholder="Optional description (internal — customers don't see this)"
|
||||
className="bg-slate-900 text-sm"
|
||||
placeholder="Add a short description (internal — customers don't see this)"
|
||||
aria-label="Flow description"
|
||||
className="w-full max-w-[78ch] rounded-md border border-transparent bg-transparent px-2 py-1 text-[13px] text-muted-foreground outline-none transition-colors placeholder:text-muted-foreground/60 hover:bg-muted/50 focus:border-primary focus:bg-transparent focus:text-foreground"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: BuilderState["status"] }) {
|
||||
const cls = {
|
||||
draft: "border-slate-700 bg-slate-800 text-slate-300",
|
||||
active: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
|
||||
archived: "border-slate-700 bg-slate-800/50 text-slate-500",
|
||||
function StatusChip({ status }: { status: BuilderState["status"] }) {
|
||||
const cfg = {
|
||||
draft: {
|
||||
// Neutral, not amber — amber is reserved for the adjacent
|
||||
// "Edited" dirty signal, so the two don't read as the same alert.
|
||||
cls: "border-border bg-muted text-muted-foreground",
|
||||
label: "Draft",
|
||||
},
|
||||
active: {
|
||||
cls: "border-emerald-600/40 bg-emerald-500/10 text-emerald-300",
|
||||
label: "Active",
|
||||
},
|
||||
archived: {
|
||||
cls: "border-border bg-muted/50 text-muted-foreground",
|
||||
label: "Archived",
|
||||
},
|
||||
}[status];
|
||||
return (
|
||||
<Badge variant="outline" className={cn("shrink-0", cls)}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</Badge>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11.5px] font-medium",
|
||||
cfg.cls,
|
||||
)}
|
||||
>
|
||||
<CircleDot className="h-3 w-3" />
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
46
src/components/flows/shared.test.ts
Normal file
46
src/components/flows/shared.test.ts
Normal 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());
|
||||
});
|
||||
});
|
||||
@ -28,7 +28,9 @@ import {
|
||||
Tag,
|
||||
UserPlus,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
} from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ============================================================
|
||||
// Node-type union — single source of truth for every place the UI
|
||||
@ -39,16 +41,16 @@ import {
|
||||
// ============================================================
|
||||
|
||||
export type NodeType =
|
||||
| "start"
|
||||
| "send_message"
|
||||
| "send_buttons"
|
||||
| "send_list"
|
||||
| "send_media"
|
||||
| "collect_input"
|
||||
| "condition"
|
||||
| "set_tag"
|
||||
| "handoff"
|
||||
| "end";
|
||||
| 'start'
|
||||
| 'send_message'
|
||||
| 'send_buttons'
|
||||
| 'send_list'
|
||||
| 'send_media'
|
||||
| 'collect_input'
|
||||
| 'condition'
|
||||
| 'set_tag'
|
||||
| 'handoff'
|
||||
| 'end';
|
||||
|
||||
export interface BuilderNode {
|
||||
node_key: string;
|
||||
@ -65,54 +67,212 @@ export interface BuilderNode {
|
||||
// the user sees a node summary.
|
||||
// ============================================================
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// Node categories — buckets the add-step menu groups types under so
|
||||
// the picker stays scannable as the type list grows, and so a fork
|
||||
// adding its own node types has an obvious place to slot them.
|
||||
//
|
||||
// Note there's no "Events / Triggers" category: in wacrm a flow is
|
||||
// triggered by flow-level config (`trigger_type`), not by a node on
|
||||
// the canvas, so `start` is just the entry point under Flow control.
|
||||
// ------------------------------------------------------------
|
||||
|
||||
export type NodeCategory = 'messaging' | 'logic' | 'flow';
|
||||
|
||||
/** Category labels + the order they render in the add-step menu. */
|
||||
export const NODE_CATEGORIES: { id: NodeCategory; label: string }[] = [
|
||||
{ id: 'messaging', label: 'Messaging' },
|
||||
{ id: 'logic', label: 'Logic & data' },
|
||||
{ id: 'flow', label: 'Flow control' },
|
||||
];
|
||||
|
||||
export const NODE_META: Record<
|
||||
NodeType,
|
||||
{ label: string; icon: typeof Workflow; color: string }
|
||||
{
|
||||
label: string;
|
||||
icon: typeof Workflow;
|
||||
color: string;
|
||||
blurb: string;
|
||||
category: NodeCategory;
|
||||
}
|
||||
> = {
|
||||
start: { label: "Start", icon: PlayCircle, color: "text-emerald-400" },
|
||||
start: {
|
||||
label: 'Start',
|
||||
icon: PlayCircle,
|
||||
color: 'text-emerald-400',
|
||||
blurb: 'Entry point of the flow',
|
||||
category: 'flow',
|
||||
},
|
||||
send_message: {
|
||||
label: "Send message",
|
||||
label: 'Send message',
|
||||
icon: MessageCircle,
|
||||
color: "text-sky-400",
|
||||
color: 'text-sky-400',
|
||||
blurb: 'Sends a WhatsApp text message',
|
||||
category: 'messaging',
|
||||
},
|
||||
send_buttons: {
|
||||
label: "Send buttons",
|
||||
label: 'Send buttons',
|
||||
icon: ListChecks,
|
||||
color: "text-primary",
|
||||
color: 'text-primary',
|
||||
blurb: 'Sends quick-reply buttons',
|
||||
category: 'messaging',
|
||||
},
|
||||
send_list: {
|
||||
label: "Send list",
|
||||
label: 'Send list',
|
||||
icon: ListPlus,
|
||||
color: "text-indigo-400",
|
||||
color: 'text-indigo-400',
|
||||
blurb: 'Sends a tappable list of options',
|
||||
category: 'messaging',
|
||||
},
|
||||
send_media: {
|
||||
label: "Send media",
|
||||
label: 'Send media',
|
||||
icon: Paperclip,
|
||||
color: "text-cyan-400",
|
||||
color: 'text-cyan-400',
|
||||
blurb: 'Sends an image, video, or document',
|
||||
category: 'messaging',
|
||||
},
|
||||
collect_input: {
|
||||
label: "Collect input",
|
||||
label: 'Collect input',
|
||||
icon: Inbox,
|
||||
color: "text-teal-400",
|
||||
color: 'text-teal-400',
|
||||
blurb: 'Asks a question, saves the reply',
|
||||
category: 'logic',
|
||||
},
|
||||
condition: {
|
||||
label: "If / else",
|
||||
label: 'If / else',
|
||||
icon: GitFork,
|
||||
color: "text-fuchsia-400",
|
||||
color: 'text-fuchsia-400',
|
||||
blurb: 'Branches on a rule',
|
||||
category: 'logic',
|
||||
},
|
||||
set_tag: {
|
||||
label: "Tag contact",
|
||||
label: 'Tag contact',
|
||||
icon: Tag,
|
||||
color: "text-pink-400",
|
||||
color: 'text-pink-400',
|
||||
blurb: 'Adds or removes a contact tag',
|
||||
category: 'logic',
|
||||
},
|
||||
handoff: {
|
||||
label: "Handoff to agent",
|
||||
label: 'Handoff to agent',
|
||||
icon: UserPlus,
|
||||
color: "text-amber-400",
|
||||
color: 'text-amber-400',
|
||||
blurb: 'Hands the conversation to a human',
|
||||
category: 'flow',
|
||||
},
|
||||
end: {
|
||||
label: 'End',
|
||||
icon: Flag,
|
||||
color: 'text-muted-foreground',
|
||||
blurb: 'Ends the flow',
|
||||
category: 'flow',
|
||||
},
|
||||
end: { label: "End", icon: Flag, color: "text-slate-400" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Bucket an ordered list of node types by category, preserving both
|
||||
* the category order (NODE_CATEGORIES) and the within-category order
|
||||
* of the input list. Empty categories are dropped. Used by both the
|
||||
* canvas and list add-step menus so they stay in lockstep.
|
||||
*/
|
||||
export function groupNodeTypesByCategory(
|
||||
types: NodeType[]
|
||||
): { id: NodeCategory; label: string; types: NodeType[] }[] {
|
||||
return NODE_CATEGORIES.map(({ id, label }) => ({
|
||||
id,
|
||||
label,
|
||||
types: types.filter((t) => NODE_META[t].category === id),
|
||||
})).filter((group) => group.types.length > 0);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Per-node-type color system.
|
||||
//
|
||||
// Each node type gets its own hue so the canvas reads at a glance —
|
||||
// what KIND of step is this. Kept as raw oklch (not Tailwind classes)
|
||||
// so a node card can tint its icon chip, type label, selection ring,
|
||||
// and edge ports from one source, the way the Flow Builder design
|
||||
// handoff does. Hues sit in the same oklch family as the app tokens
|
||||
// in globals.css; they don't replace --primary (the accent), they
|
||||
// complement it. `nodeColors()` derives the soft/ring/text variants.
|
||||
// ============================================================
|
||||
|
||||
const NODE_HUE: Record<NodeType, { l: number; c: number; h: number }> = {
|
||||
start: { l: 0.62, c: 0.13, h: 162 }, // emerald — the start, echoes WhatsApp green
|
||||
send_message: { l: 0.6, c: 0.18, h: 293 }, // violet — the workhorse
|
||||
send_buttons: { l: 0.62, c: 0.16, h: 254 }, // cobalt
|
||||
send_list: { l: 0.62, c: 0.15, h: 277 }, // indigo
|
||||
send_media: { l: 0.65, c: 0.12, h: 210 }, // sky
|
||||
collect_input: { l: 0.65, c: 0.1, h: 185 }, // teal — capture
|
||||
condition: { l: 0.72, c: 0.15, h: 65 }, // amber — a fork in the road
|
||||
set_tag: { l: 0.65, c: 0.15, h: 350 }, // pink
|
||||
handoff: { l: 0.65, c: 0.17, h: 16 }, // rose — hands off
|
||||
end: { l: 0.55, c: 0.01, h: 260 }, // neutral grey — terminal
|
||||
};
|
||||
|
||||
export interface NodeColors {
|
||||
/** Full-strength hue — icon glyph, selection ring, port fill. */
|
||||
solid: string;
|
||||
/** ~14% tint — icon chip background, soft fills. */
|
||||
soft: string;
|
||||
/** ~45% tint — hover border / focus ring. */
|
||||
ring: string;
|
||||
/** Hue for the uppercase type label, kept readable in BOTH modes. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function nodeColors(type: NodeType): NodeColors {
|
||||
const t = NODE_HUE[type];
|
||||
const solid = `oklch(${t.l} ${t.c} ${t.h})`;
|
||||
return {
|
||||
solid,
|
||||
soft: `oklch(${t.l} ${t.c} ${t.h} / 0.14)`,
|
||||
ring: `oklch(${t.l} ${t.c} ${t.h} / 0.45)`,
|
||||
// Blend the hue toward the live --foreground token so the label
|
||||
// holds contrast in BOTH modes: in dark mode --foreground is
|
||||
// near-white (the label lightens to read on the dark card), in
|
||||
// light mode it's near-black (the label darkens to read on the
|
||||
// white card). The old fixed-light value only worked on dark.
|
||||
text: `color-mix(in oklch, ${solid}, var(--foreground) 38%)`,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Shared node icon chip — the per-type colored glyph badge used in
|
||||
// the canvas node card, list-view card, inspector header, and the
|
||||
// add-step menu. One component so a styling change (radius, contrast,
|
||||
// hover) lands in every place at once and the `nodeColors()` lookup
|
||||
// lives in exactly one spot.
|
||||
// ============================================================
|
||||
|
||||
export function NodeIconChip({
|
||||
type,
|
||||
size = 24,
|
||||
iconSize = 14,
|
||||
className,
|
||||
}: {
|
||||
type: NodeType;
|
||||
/** Chip side length in px. */
|
||||
size?: number;
|
||||
/** Glyph side length in px. */
|
||||
iconSize?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const meta = NODE_META[type];
|
||||
const c = nodeColors(type);
|
||||
const Icon = meta.icon;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-lg',
|
||||
className
|
||||
)}
|
||||
style={{ width: size, height: size, background: c.soft, color: c.solid }}
|
||||
>
|
||||
<Icon size={iconSize} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Pure editing helpers — used by forms in both views.
|
||||
// ============================================================
|
||||
@ -127,8 +287,8 @@ export function slugify(s: string, fallback: string): string {
|
||||
const cleaned = s
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
@ -140,37 +300,39 @@ export function slugify(s: string, fallback: string): string {
|
||||
// ============================================================
|
||||
|
||||
export function truncate(s: string, max = 80): string {
|
||||
const clean = s.replace(/\s+/g, " ").trim();
|
||||
const clean = s.replace(/\s+/g, ' ').trim();
|
||||
if (clean.length <= max) return clean;
|
||||
return clean.slice(0, max - 1) + "…";
|
||||
return clean.slice(0, max - 1) + '…';
|
||||
}
|
||||
|
||||
export function summarizeNode(node: BuilderNode): string | null {
|
||||
const cfg = node.config;
|
||||
switch (node.node_type) {
|
||||
case "start":
|
||||
case "end":
|
||||
case 'start':
|
||||
case 'end':
|
||||
return null;
|
||||
case "send_message": {
|
||||
const text = typeof cfg.text === "string" ? cfg.text : "";
|
||||
case 'send_message': {
|
||||
const text = typeof cfg.text === 'string' ? cfg.text : '';
|
||||
return text.length > 0 ? truncate(text) : null;
|
||||
}
|
||||
case "send_buttons": {
|
||||
const text = typeof cfg.text === "string" ? cfg.text : "";
|
||||
case 'send_buttons': {
|
||||
const text = typeof cfg.text === 'string' ? cfg.text : '';
|
||||
const buttons = Array.isArray(cfg.buttons)
|
||||
? (cfg.buttons as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const titles = buttons
|
||||
.map((b) => (typeof b.title === "string" ? b.title : ""))
|
||||
.map((b) => (typeof b.title === 'string' ? b.title : ''))
|
||||
.filter(Boolean)
|
||||
.join(" / ");
|
||||
.join(' / ');
|
||||
if (text.length > 0) {
|
||||
return titles ? `${truncate(text, 40)} · ${truncate(titles, 35)}` : truncate(text);
|
||||
return titles
|
||||
? `${truncate(text, 40)} · ${truncate(titles, 35)}`
|
||||
: truncate(text);
|
||||
}
|
||||
return titles || null;
|
||||
}
|
||||
case "send_list": {
|
||||
const text = typeof cfg.text === "string" ? cfg.text : "";
|
||||
case 'send_list': {
|
||||
const text = typeof cfg.text === 'string' ? cfg.text : '';
|
||||
const sections = Array.isArray(cfg.sections)
|
||||
? (cfg.sections as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
@ -180,75 +342,81 @@ export function summarizeNode(node: BuilderNode): string | null {
|
||||
}, 0);
|
||||
if (text.length > 0) {
|
||||
return rowCount > 0
|
||||
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? "" : "s"}`
|
||||
? `${truncate(text, 50)} · ${rowCount} option${rowCount === 1 ? '' : 's'}`
|
||||
: truncate(text);
|
||||
}
|
||||
return rowCount > 0
|
||||
? `${rowCount} option${rowCount === 1 ? "" : "s"} across ${sections.length} section${sections.length === 1 ? "" : "s"}`
|
||||
? `${rowCount} option${rowCount === 1 ? '' : 's'} across ${sections.length} section${sections.length === 1 ? '' : 's'}`
|
||||
: null;
|
||||
}
|
||||
case "send_media": {
|
||||
case 'send_media': {
|
||||
const mediaType =
|
||||
typeof cfg.media_type === "string" ? cfg.media_type : "";
|
||||
const filename = typeof cfg.filename === "string" ? cfg.filename : "";
|
||||
const url = typeof cfg.media_url === "string" ? cfg.media_url : "";
|
||||
const caption = typeof cfg.caption === "string" ? cfg.caption : "";
|
||||
typeof cfg.media_type === 'string' ? cfg.media_type : '';
|
||||
const filename = typeof cfg.filename === 'string' ? cfg.filename : '';
|
||||
const url = typeof cfg.media_url === 'string' ? cfg.media_url : '';
|
||||
const caption = typeof cfg.caption === 'string' ? cfg.caption : '';
|
||||
const label = mediaType
|
||||
? mediaType.charAt(0).toUpperCase() + mediaType.slice(1)
|
||||
: "Media";
|
||||
: 'Media';
|
||||
if (!url) return `${label} (no file uploaded)`;
|
||||
const name = filename || url.split("/").pop() || "file";
|
||||
const name = filename || url.split('/').pop() || 'file';
|
||||
return caption
|
||||
? `${label}: ${truncate(name, 30)} · ${truncate(caption, 40)}`
|
||||
: `${label}: ${truncate(name, 60)}`;
|
||||
}
|
||||
case "collect_input": {
|
||||
const prompt = typeof cfg.prompt_text === "string" ? cfg.prompt_text : "";
|
||||
const varKey = typeof cfg.var_key === "string" ? cfg.var_key : "";
|
||||
case 'collect_input': {
|
||||
const prompt = typeof cfg.prompt_text === 'string' ? cfg.prompt_text : '';
|
||||
const varKey = typeof cfg.var_key === 'string' ? cfg.var_key : '';
|
||||
if (prompt.length > 0) {
|
||||
return varKey ? `${truncate(prompt, 50)} → vars.${varKey}` : truncate(prompt);
|
||||
return varKey
|
||||
? `${truncate(prompt, 50)} → vars.${varKey}`
|
||||
: truncate(prompt);
|
||||
}
|
||||
return varKey ? `→ vars.${varKey}` : null;
|
||||
}
|
||||
case "condition": {
|
||||
case 'condition': {
|
||||
const subjectKey =
|
||||
typeof cfg.subject_key === "string" ? cfg.subject_key : "";
|
||||
typeof cfg.subject_key === 'string' ? cfg.subject_key : '';
|
||||
if (!subjectKey) return null;
|
||||
const subject =
|
||||
cfg.subject === "tag"
|
||||
? "tag"
|
||||
: cfg.subject === "contact_field"
|
||||
? "field"
|
||||
: "var";
|
||||
cfg.subject === 'tag'
|
||||
? 'tag'
|
||||
: cfg.subject === 'contact_field'
|
||||
? 'field'
|
||||
: 'var';
|
||||
const subjectStr =
|
||||
subject === "tag" ? `has tag ${truncate(subjectKey, 24)}` : `${subject}.${subjectKey}`;
|
||||
subject === 'tag'
|
||||
? `has tag ${truncate(subjectKey, 24)}`
|
||||
: `${subject}.${subjectKey}`;
|
||||
const op =
|
||||
cfg.operator === "equals"
|
||||
? "=="
|
||||
: cfg.operator === "contains"
|
||||
? "contains"
|
||||
: cfg.operator === "present"
|
||||
? "exists"
|
||||
: cfg.operator === "absent"
|
||||
? "missing"
|
||||
: "";
|
||||
const value = typeof cfg.value === "string" ? cfg.value : "";
|
||||
cfg.operator === 'equals'
|
||||
? '=='
|
||||
: cfg.operator === 'contains'
|
||||
? 'contains'
|
||||
: cfg.operator === 'present'
|
||||
? 'exists'
|
||||
: cfg.operator === 'absent'
|
||||
? 'missing'
|
||||
: '';
|
||||
const value = typeof cfg.value === 'string' ? cfg.value : '';
|
||||
const valStr =
|
||||
(cfg.operator === "equals" || cfg.operator === "contains") && value
|
||||
(cfg.operator === 'equals' || cfg.operator === 'contains') && value
|
||||
? ` "${truncate(value, 20)}"`
|
||||
: "";
|
||||
return subject === "tag" ? subjectStr : `${subjectStr} ${op}${valStr}`;
|
||||
: '';
|
||||
return subject === 'tag' ? subjectStr : `${subjectStr} ${op}${valStr}`;
|
||||
}
|
||||
case "set_tag": {
|
||||
const mode = cfg.mode === "remove" ? "Remove" : "Add";
|
||||
const tagId = typeof cfg.tag_id === "string" ? cfg.tag_id : "";
|
||||
case 'set_tag': {
|
||||
const mode = cfg.mode === 'remove' ? 'Remove' : 'Add';
|
||||
const tagId = typeof cfg.tag_id === 'string' ? cfg.tag_id : '';
|
||||
// No tag name available without an async lookup here; show a
|
||||
// short prefix of the UUID so users can disambiguate between
|
||||
// multiple set_tag nodes at a glance.
|
||||
return tagId ? `${mode} tag ${tagId.slice(0, 8)}…` : `${mode} tag (none picked)`;
|
||||
return tagId
|
||||
? `${mode} tag ${tagId.slice(0, 8)}…`
|
||||
: `${mode} tag (none picked)`;
|
||||
}
|
||||
case "handoff": {
|
||||
const note = typeof cfg.note === "string" ? cfg.note : "";
|
||||
case 'handoff': {
|
||||
const note = typeof cfg.note === 'string' ? cfg.note : '';
|
||||
return note.length > 0 ? truncate(note) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,7 +30,7 @@ export function ValidationPanel() {
|
||||
// sticky-positioned over scrolled-behind node cards (a translucent
|
||||
// bg-emerald-500/10 would bleed through ugly).
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-600/50 bg-slate-950 p-3 text-sm font-medium text-emerald-300">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-emerald-600/50 bg-background p-3 text-sm font-medium text-emerald-300">
|
||||
<CircleCheck className="h-4 w-4 shrink-0" />
|
||||
No issues. Ready to activate.
|
||||
</div>
|
||||
@ -41,11 +41,11 @@ export function ValidationPanel() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border bg-slate-950 p-3",
|
||||
"rounded-lg border bg-background p-3",
|
||||
errors.length > 0 ? "border-red-500/40" : "border-amber-500/40",
|
||||
)}
|
||||
>
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-slate-400">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{errors.length > 0 ? (
|
||||
<CircleAlert className="h-4 w-4 text-red-400" />
|
||||
) : (
|
||||
@ -85,7 +85,7 @@ export function IssueLine({
|
||||
<CircleAlert className={cn("mt-0.5 h-3 w-3 shrink-0", iconTone)} />
|
||||
<span className="min-w-0 flex-1">
|
||||
{issue.node_key && (
|
||||
<code className="mr-1 rounded bg-slate-800 px-1 py-0.5 text-[10px] text-slate-400">
|
||||
<code className="mr-1 rounded bg-muted px-1 py-0.5 text-[10px] text-muted-foreground">
|
||||
{issue.node_key}
|
||||
</code>
|
||||
)}
|
||||
@ -103,7 +103,7 @@ export function IssueLine({
|
||||
type="button"
|
||||
onClick={() => onJump(issue.node_key!)}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-slate-800/60",
|
||||
"flex w-full items-start gap-2 rounded-md px-2 py-1 text-left text-xs transition-colors hover:bg-muted/60",
|
||||
tone,
|
||||
)}
|
||||
aria-label={`Jump to node ${issue.node_key}`}
|
||||
|
||||
@ -117,8 +117,8 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className="flex h-full w-70 items-center justify-center border-l border-slate-800 bg-slate-900">
|
||||
<p className="text-sm text-slate-500">Select a conversation</p>
|
||||
<div className="flex h-full w-70 items-center justify-center border-l border-border bg-card">
|
||||
<p className="text-sm text-muted-foreground">Select a conversation</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -127,12 +127,12 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
|
||||
const initials = displayName.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-70 flex-col border-l border-slate-800 bg-slate-900">
|
||||
<div className="flex h-full w-70 flex-col border-l border-border bg-card">
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4">
|
||||
{/* Contact Info */}
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-700 text-lg font-semibold text-white">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted text-lg font-semibold text-foreground">
|
||||
{contact.avatar_url ? (
|
||||
<img
|
||||
src={contact.avatar_url}
|
||||
@ -143,11 +143,11 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
<h3 className="mt-3 text-sm font-semibold text-white">
|
||||
<h3 className="mt-3 text-sm font-semibold text-foreground">
|
||||
{displayName}
|
||||
</h3>
|
||||
{contact.company && (
|
||||
<p className="text-xs text-slate-400">{contact.company}</p>
|
||||
<p className="text-xs text-muted-foreground">{contact.company}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -155,37 +155,37 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
|
||||
<div className="mt-4 space-y-2">
|
||||
<button
|
||||
onClick={handleCopyPhone}
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-800"
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted"
|
||||
>
|
||||
<Phone className="h-4 w-4 text-slate-500" />
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="flex-1 text-left">{contact.phone}</span>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-primary" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3 text-slate-600" />
|
||||
<Copy className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{contact.email && (
|
||||
<div className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-300">
|
||||
<Mail className="h-4 w-4 text-slate-500" />
|
||||
<div className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="truncate">{contact.email}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-4 border-t border-slate-800" />
|
||||
<div className="my-4 border-t border-border" />
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<TagIcon className="h-3 w-3" />
|
||||
Tags
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tags.length === 0 ? (
|
||||
<p className="px-1 text-xs text-slate-600">No tags</p>
|
||||
<p className="px-1 text-xs text-muted-foreground">No tags</p>
|
||||
) : (
|
||||
tags.map((tag) => (
|
||||
<span
|
||||
@ -204,27 +204,27 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-4 border-t border-slate-800" />
|
||||
<div className="my-4 border-t border-border" />
|
||||
|
||||
{/* Active Deals */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
Active Deals
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
{deals.length === 0 ? (
|
||||
<p className="px-1 text-xs text-slate-600">No deals</p>
|
||||
<p className="px-1 text-xs text-muted-foreground">No deals</p>
|
||||
) : (
|
||||
deals.map((deal) => (
|
||||
<div
|
||||
key={deal.id}
|
||||
className="rounded-lg bg-slate-800 px-3 py-2"
|
||||
className="rounded-lg bg-muted px-3 py-2"
|
||||
>
|
||||
<p className="text-sm font-medium text-white">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{deal.title}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center justify-between text-xs text-slate-400">
|
||||
<div className="mt-1 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{deal.currency ?? "$"}
|
||||
{deal.value.toLocaleString()}
|
||||
@ -248,11 +248,11 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-4 border-t border-slate-800" />
|
||||
<div className="my-4 border-t border-border" />
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
<StickyNote className="h-3 w-3" />
|
||||
Notes
|
||||
</div>
|
||||
@ -263,7 +263,7 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
placeholder="Add a note..."
|
||||
rows={2}
|
||||
className="flex-1 resize-none rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-xs text-white placeholder-slate-500 outline-none focus:border-primary/50"
|
||||
className="flex-1 resize-none rounded-lg border border-border bg-muted px-3 py-2 text-xs text-foreground placeholder-muted-foreground outline-none focus:border-primary/50"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -279,12 +279,12 @@ export function ContactSidebar({ contact }: ContactSidebarProps) {
|
||||
{notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="rounded-lg bg-slate-800 px-3 py-2"
|
||||
className="rounded-lg bg-muted px-3 py-2"
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-xs text-slate-300">
|
||||
<p className="whitespace-pre-wrap text-xs text-muted-foreground">
|
||||
{note.note_text}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-slate-600">
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
{format(new Date(note.created_at), "MMM d, yyyy HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -33,11 +33,14 @@ interface ConversationListProps {
|
||||
const STATUS_COLORS: Record<ConversationStatus, string> = {
|
||||
open: "bg-primary",
|
||||
pending: "bg-amber-500",
|
||||
closed: "bg-slate-500",
|
||||
closed: "bg-muted-foreground",
|
||||
};
|
||||
|
||||
const FILTER_OPTIONS: { label: string; value: ConversationStatus | "all" }[] = [
|
||||
type InboxFilter = ConversationStatus | "all" | "unread";
|
||||
|
||||
const FILTER_OPTIONS: { label: string; value: InboxFilter }[] = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Unread", value: "unread" },
|
||||
{ label: "Open", value: "open" },
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Closed", value: "closed" },
|
||||
@ -51,7 +54,7 @@ export function ConversationList({
|
||||
resyncToken = 0,
|
||||
}: ConversationListProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<ConversationStatus | "all">("all");
|
||||
const [filter, setFilter] = useState<InboxFilter>("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Keep the latest callback in a ref so the fetch effect below can
|
||||
@ -110,7 +113,9 @@ export function ConversationList({
|
||||
const filtered = useMemo(() => {
|
||||
let result = conversations;
|
||||
|
||||
if (filter !== "all") {
|
||||
if (filter === "unread") {
|
||||
result = result.filter((c) => c.unread_count > 0);
|
||||
} else if (filter !== "all") {
|
||||
result = result.filter((c) => c.status === filter);
|
||||
}
|
||||
|
||||
@ -147,27 +152,27 @@ export function ConversationList({
|
||||
// w-full on mobile so the list occupies the whole viewport when it's
|
||||
// the single pane showing; fixed 320px on desktop where it shares the
|
||||
// row with the thread + contact sidebar.
|
||||
<div className="flex h-full w-full flex-col border-r border-slate-800 bg-slate-900 lg:w-80">
|
||||
<div className="flex h-full w-full flex-col border-r border-border bg-card lg:w-80">
|
||||
{/* Search + Filter */}
|
||||
<div className="space-y-2 border-b border-slate-800 p-3">
|
||||
<div className="space-y-2 border-b border-border p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={handleSearchChange}
|
||||
placeholder="Search conversations..."
|
||||
className="border-slate-700 bg-slate-800 pl-9 text-sm text-white placeholder-slate-500 focus:border-primary/50"
|
||||
className="border-border bg-muted pl-9 text-sm text-foreground placeholder-muted-foreground focus:border-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-slate-400 hover:text-white rounded-md hover:bg-slate-800">
|
||||
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground rounded-md hover:bg-muted">
|
||||
{activeFilter?.label ?? "All"}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="border-slate-700 bg-slate-800"
|
||||
className="border-border bg-popover"
|
||||
>
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
@ -177,7 +182,7 @@ export function ConversationList({
|
||||
"text-sm",
|
||||
filter === opt.value
|
||||
? "text-primary"
|
||||
: "text-slate-300"
|
||||
: "text-popover-foreground"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
@ -200,7 +205,7 @@ export function ConversationList({
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center">
|
||||
<p className="text-sm text-slate-500">No conversations found</p>
|
||||
<p className="text-sm text-muted-foreground">No conversations found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
@ -248,12 +253,12 @@ function ConversationItem({
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-slate-800/50",
|
||||
isActive && "border-l-2 border-primary bg-slate-800/70"
|
||||
"flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-muted/50",
|
||||
isActive && "border-l-2 border-primary bg-muted/70"
|
||||
)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium text-foreground">
|
||||
{contact?.avatar_url ? (
|
||||
<img
|
||||
src={contact.avatar_url}
|
||||
@ -268,13 +273,13 @@ function ConversationItem({
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm font-medium text-white">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-slate-500">{timeAgo}</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">{timeAgo}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs text-slate-400">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{conversation.last_message_text || "No messages yet"}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
|
||||
@ -95,7 +95,7 @@ export function MessageActions({
|
||||
<div
|
||||
data-touch-open={touchOpen || pickerOpen ? "true" : undefined}
|
||||
className={cn(
|
||||
"absolute -top-3 z-10 flex h-7 items-center gap-0.5 rounded-full border border-slate-700 bg-slate-900/95 px-1 shadow-md backdrop-blur-sm transition-opacity",
|
||||
"absolute -top-3 z-10 flex h-7 items-center gap-0.5 rounded-full border border-border bg-popover/95 px-1 shadow-md backdrop-blur-sm transition-opacity",
|
||||
"opacity-0 group-hover/actions:opacity-100 group-focus-within/actions:opacity-100",
|
||||
"data-[touch-open=true]:opacity-100",
|
||||
isAgent ? "right-3" : "left-3",
|
||||
@ -103,7 +103,7 @@ export function MessageActions({
|
||||
>
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<PopoverTrigger
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="React"
|
||||
>
|
||||
<SmilePlus className="h-3.5 w-3.5" />
|
||||
@ -117,7 +117,7 @@ export function MessageActions({
|
||||
key={e}
|
||||
type="button"
|
||||
onClick={() => handlePickEmoji(e)}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-lg leading-none transition-transform hover:scale-125 hover:bg-slate-700"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-lg leading-none transition-transform hover:scale-125 hover:bg-muted"
|
||||
aria-label={`React with ${e}`}
|
||||
>
|
||||
{e}
|
||||
@ -128,7 +128,7 @@ export function MessageActions({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReply}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Reply"
|
||||
>
|
||||
<CornerUpLeft className="h-3.5 w-3.5" />
|
||||
@ -136,7 +136,7 @@ export function MessageActions({
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-slate-300 hover:bg-slate-700 hover:text-white"
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-popover-foreground hover:bg-muted hover:text-foreground"
|
||||
aria-label="Copy"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
|
||||
@ -30,11 +30,11 @@ interface MessageBubbleProps {
|
||||
function StatusIcon({ status }: { status: Message["status"] }) {
|
||||
switch (status) {
|
||||
case "sending":
|
||||
return <Clock className="h-3 w-3 text-slate-400" />;
|
||||
return <Clock className="h-3 w-3 text-muted-foreground" />;
|
||||
case "sent":
|
||||
return <Check className="h-3 w-3 text-slate-400" />;
|
||||
return <Check className="h-3 w-3 text-muted-foreground" />;
|
||||
case "delivered":
|
||||
return <CheckCheck className="h-3 w-3 text-slate-400" />;
|
||||
return <CheckCheck className="h-3 w-3 text-muted-foreground" />;
|
||||
case "read":
|
||||
return <CheckCheck className="h-3 w-3 text-blue-400" />;
|
||||
case "failed":
|
||||
@ -46,8 +46,8 @@ function StatusIcon({ status }: { status: Message["status"] }) {
|
||||
|
||||
function MediaUnavailable({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-slate-700/40 px-3 py-2 text-xs text-slate-300">
|
||||
<ImageOff className="h-4 w-4 shrink-0 text-slate-500" />
|
||||
<div className="flex items-center gap-2 rounded-lg bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
|
||||
<ImageOff className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span>{label} unavailable</span>
|
||||
</div>
|
||||
);
|
||||
@ -92,15 +92,15 @@ function MediaImage({ url, alt }: { url: string; alt: string }) {
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-slate-700">
|
||||
<ImageOff className="h-8 w-8 text-slate-500" />
|
||||
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-muted">
|
||||
<ImageOff className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-slate-700">
|
||||
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-muted">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
@ -181,9 +181,9 @@ function MessageContent({ message }: { message: Message }) {
|
||||
href={message.media_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 rounded-lg bg-slate-700/50 px-3 py-2 text-sm hover:bg-slate-700"
|
||||
className="flex items-center gap-2 rounded-lg bg-muted/50 px-3 py-2 text-sm hover:bg-muted"
|
||||
>
|
||||
<FileText className="h-5 w-5 shrink-0 text-slate-400" />
|
||||
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">
|
||||
{message.content_text || "Document"}
|
||||
</span>
|
||||
@ -208,7 +208,7 @@ function MessageContent({ message }: { message: Message }) {
|
||||
case "location":
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
<MapPin className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span>{message.content_text || "Location shared"}</span>
|
||||
</div>
|
||||
);
|
||||
@ -221,7 +221,7 @@ function MessageContent({ message }: { message: Message }) {
|
||||
// tap rather than the customer typing the same words.
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-slate-400">
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<CornerDownLeft className="h-3 w-3" />
|
||||
Button reply
|
||||
</span>
|
||||
@ -265,11 +265,15 @@ export function MessageBubble({
|
||||
"relative rounded-2xl px-3 py-2",
|
||||
isAgent
|
||||
? "rounded-br-md bg-primary text-primary-foreground"
|
||||
: "rounded-bl-md bg-slate-800 text-slate-100",
|
||||
: "rounded-bl-md bg-muted text-foreground",
|
||||
)}
|
||||
>
|
||||
{reply && (
|
||||
<ReplyQuote authorLabel={reply.authorLabel} preview={reply.preview} />
|
||||
<ReplyQuote
|
||||
authorLabel={reply.authorLabel}
|
||||
preview={reply.preview}
|
||||
onPrimary={isAgent}
|
||||
/>
|
||||
)}
|
||||
<MessageContent message={message} />
|
||||
<div
|
||||
@ -278,7 +282,18 @@ export function MessageBubble({
|
||||
isAgent ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] text-white/60">{time}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px]",
|
||||
// Outbound bubbles sit on the primary fill, so the
|
||||
// timestamp must read against that (not the neutral
|
||||
// foreground) — otherwise it goes low-contrast in light
|
||||
// mode. Inbound bubbles use the muted surface.
|
||||
isAgent ? "text-primary-foreground/70" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{time}
|
||||
</span>
|
||||
{isAgent && <StatusIcon status={message.status} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,13 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, KeyboardEvent } from "react";
|
||||
import { Send, LayoutTemplate } from "lucide-react";
|
||||
import {
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
KeyboardEvent,
|
||||
} from "react";
|
||||
import {
|
||||
Send,
|
||||
LayoutTemplate,
|
||||
Paperclip,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
FileText,
|
||||
Mic,
|
||||
Square,
|
||||
X,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { GatedButton } from "@/components/ui/gated-button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { useCan } from "@/hooks/use-can";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
uploadAccountMedia,
|
||||
deleteAccountMedia,
|
||||
MEDIA_MAX_BYTES_BY_KIND,
|
||||
} from "@/lib/storage/upload-media";
|
||||
import { ReplyQuote } from "./reply-quote";
|
||||
|
||||
/** Media content types an agent can send from the composer. */
|
||||
export type ComposerMediaKind = "image" | "video" | "document" | "audio";
|
||||
|
||||
/** Supabase Storage bucket holding agent-sent chat attachments (migration 023). */
|
||||
export const CHAT_MEDIA_BUCKET = "chat-media";
|
||||
|
||||
/** Meta caps media captions at 1024 chars. Enforced here and in the send route. */
|
||||
export const MEDIA_CAPTION_MAX = 1024;
|
||||
|
||||
/** Hard cap on a single voice recording so it can't blow the upload/
|
||||
* transcode limits — auto-stops the recorder when reached. */
|
||||
const MAX_RECORDING_SECONDS = 5 * 60;
|
||||
|
||||
export interface SendMediaPayload {
|
||||
kind: ComposerMediaKind;
|
||||
/** Public chat-media URL Meta fetches at send time. */
|
||||
mediaUrl: string;
|
||||
/** Storage object path — lets the caller GC the object if the send fails. */
|
||||
path: string;
|
||||
/** Optional caption (image/video/document only). */
|
||||
caption?: string;
|
||||
/** Original file name — surfaced to the recipient for documents. */
|
||||
filename?: string;
|
||||
replyToId?: string;
|
||||
}
|
||||
|
||||
interface ReplyDraft {
|
||||
/** Internal UUID of the message being replied to — sent back through onSend. */
|
||||
id: string;
|
||||
@ -15,19 +70,52 @@ interface ReplyDraft {
|
||||
preview: string;
|
||||
}
|
||||
|
||||
// Mirrors the chat-media bucket's allowed_mime_types (migration 023) for
|
||||
// the file picker so unsupported files are rejected before upload rather
|
||||
// than failing with a confusing Storage error. Audio has no picker — it's
|
||||
// captured via the recorder.
|
||||
const PICKER_ACCEPT: Record<"image" | "video" | "document", string> = {
|
||||
image: "image/png,image/jpeg,image/webp",
|
||||
video: "video/mp4,video/3gpp",
|
||||
document:
|
||||
"application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation,text/plain",
|
||||
};
|
||||
|
||||
interface MediaDraft {
|
||||
kind: ComposerMediaKind;
|
||||
mediaUrl: string;
|
||||
/** Storage path — used to GC the object if the draft is discarded. */
|
||||
path: string;
|
||||
filename: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
interface MessageComposerProps {
|
||||
conversationId: string;
|
||||
sessionExpired: boolean;
|
||||
onSend: (text: string, replyToId?: string) => void;
|
||||
onSendMedia: (payload: SendMediaPayload) => void;
|
||||
onOpenTemplates: () => void;
|
||||
replyTo?: ReplyDraft | null;
|
||||
onClearReply?: () => void;
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Worker that encodes mic input to Ogg/Opus entirely in the browser
|
||||
* (vendored from opus-recorder into /public). Recording client-side in a
|
||||
* Meta-accepted format means no server ffmpeg / transcode step. */
|
||||
const OPUS_ENCODER_PATH = "/opus/encoderWorker.min.js";
|
||||
|
||||
export function MessageComposer({
|
||||
conversationId,
|
||||
sessionExpired,
|
||||
onSend,
|
||||
onSendMedia,
|
||||
onOpenTemplates,
|
||||
replyTo,
|
||||
onClearReply,
|
||||
@ -35,11 +123,63 @@ export function MessageComposer({
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Media attachment state. `draft` holds an uploaded-but-not-yet-sent
|
||||
// attachment; `busy` covers the upload/transcode window.
|
||||
const [draft, setDraft] = useState<MediaDraft | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
const documentInputRef = useRef<HTMLInputElement>(null);
|
||||
// Mirror of `draft` for the unmount cleanup, which can't read render
|
||||
// state. Kept in sync below so navigating away with a staged-but-unsent
|
||||
// attachment GCs the orphaned object.
|
||||
const draftRef = useRef<MediaDraft | null>(null);
|
||||
useEffect(() => {
|
||||
draftRef.current = draft;
|
||||
}, [draft]);
|
||||
|
||||
// Best-effort GC of a staged object the user never sent. Fire-and-forget.
|
||||
const removeStaged = useCallback((path: string | undefined) => {
|
||||
if (!path) return;
|
||||
void deleteAccountMedia(CHAT_MEDIA_BUCKET, path).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// Voice recording state. The recorder encodes Ogg/Opus in-browser
|
||||
// (opus-recorder) so there's no server-side transcode.
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [recordSeconds, setRecordSeconds] = useState(0);
|
||||
const recorderRef = useRef<import("opus-recorder").default | null>(null);
|
||||
const cancelledRef = useRef(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Viewers (read-only role) can browse the inbox but never send.
|
||||
// For solo users this is always true — single-owner accounts pass
|
||||
// every capability — so the disabled branch is a no-op there.
|
||||
const canSend = useCan("send-messages");
|
||||
const readOnly = !canSend;
|
||||
// Media (like free-form text) is only allowed inside the 24h window.
|
||||
const inputsDisabled = readOnly || sessionExpired;
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current !== null) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Tear down any live recording + timer on unmount so a mid-record
|
||||
// navigation doesn't leak the mic, and GC a staged-but-unsent
|
||||
// attachment so it doesn't orphan in the bucket.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimer();
|
||||
cancelledRef.current = true;
|
||||
// stop() releases the mic stream + audio context inside opus-recorder.
|
||||
void recorderRef.current?.stop().catch(() => {});
|
||||
removeStaged(draftRef.current?.path);
|
||||
};
|
||||
}, [clearTimer, removeStaged]);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
@ -83,8 +223,162 @@ export function MessageComposer({
|
||||
[adjustHeight]
|
||||
);
|
||||
|
||||
// Upload a captured file to chat-media and stage it as a draft.
|
||||
const stageUpload = useCallback(
|
||||
async (kind: ComposerMediaKind, file: File) => {
|
||||
// Per-kind ceiling mirrors Meta's caps (image 5 MB, etc.) so we
|
||||
// reject before upload rather than orphaning an object that Meta
|
||||
// would then refuse at send.
|
||||
const max = MEDIA_MAX_BYTES_BY_KIND[kind];
|
||||
if (file.size > max) {
|
||||
toast.error(
|
||||
`File is ${(file.size / 1024 / 1024).toFixed(1)} MB — ${kind} limit is ${Math.round(
|
||||
max / 1024 / 1024,
|
||||
)} MB.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
|
||||
// Replacing an existing draft? GC the previous object first.
|
||||
removeStaged(draftRef.current?.path);
|
||||
setDraft({ kind, mediaUrl: publicUrl, path, filename: file.name, caption: "" });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Upload failed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[removeStaged],
|
||||
);
|
||||
|
||||
const handlePicked = useCallback(
|
||||
(kind: "image" | "video" | "document", file: File | undefined) => {
|
||||
if (file) void stageUpload(kind, file);
|
||||
},
|
||||
[stageUpload],
|
||||
);
|
||||
|
||||
// ---- Voice recording (client-side Ogg/Opus, no server transcode) ---
|
||||
|
||||
// The encoded Ogg/Opus file from opus-recorder → upload as an audio
|
||||
// draft. WhatsApp renders Ogg/Opus as a playable voice note.
|
||||
const finalizeRecording = useCallback(
|
||||
async (bytes: Uint8Array) => {
|
||||
// Uint8Array is a valid BlobPart at runtime; the cast sidesteps the
|
||||
// lib.dom ArrayBufferLike-vs-ArrayBuffer generic mismatch.
|
||||
const file = new File([bytes as unknown as BlobPart], `voice-${Date.now()}.ogg`, {
|
||||
type: "audio/ogg",
|
||||
});
|
||||
if (file.size === 0) return; // cancelled / empty take
|
||||
if (file.size > MEDIA_MAX_BYTES_BY_KIND.audio) {
|
||||
toast.error("Recording is too long (over 16 MB).");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const { publicUrl, path } = await uploadAccountMedia(CHAT_MEDIA_BUCKET, file);
|
||||
removeStaged(draftRef.current?.path);
|
||||
setDraft({ kind: "audio", mediaUrl: publicUrl, path, filename: file.name, caption: "" });
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Upload failed.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[removeStaged],
|
||||
);
|
||||
|
||||
const startRecording = useCallback(async () => {
|
||||
if (inputsDisabled || busy || recording) return;
|
||||
if (!navigator.mediaDevices?.getUserMedia || typeof AudioContext === "undefined") {
|
||||
toast.error("Voice recording isn't supported in this browser.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Lazy-load the encoder (≈400 KB worker) only when the user records,
|
||||
// keeping it out of the main bundle.
|
||||
const { default: Recorder } = await import("opus-recorder");
|
||||
const recorder = new Recorder({
|
||||
encoderPath: OPUS_ENCODER_PATH,
|
||||
numberOfChannels: 1,
|
||||
encoderApplication: 2048, // VOIP — tuned for speech
|
||||
encoderSampleRate: 48000,
|
||||
streamPages: false, // one callback with the complete file on stop
|
||||
});
|
||||
cancelledRef.current = false;
|
||||
recorder.ondataavailable = (bytes) => {
|
||||
if (cancelledRef.current) return;
|
||||
void finalizeRecording(bytes);
|
||||
};
|
||||
recorderRef.current = recorder;
|
||||
await recorder.start();
|
||||
setRecording(true);
|
||||
setRecordSeconds(0);
|
||||
timerRef.current = setInterval(() => setRecordSeconds((s) => s + 1), 1000);
|
||||
} catch {
|
||||
void recorderRef.current?.stop().catch(() => {});
|
||||
recorderRef.current = null;
|
||||
toast.error("Microphone access denied or unavailable.");
|
||||
}
|
||||
}, [inputsDisabled, busy, recording, finalizeRecording]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
clearTimer();
|
||||
setRecording(false);
|
||||
void recorderRef.current?.stop().catch(() => {});
|
||||
}, [clearTimer]);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
cancelledRef.current = true;
|
||||
clearTimer();
|
||||
setRecording(false);
|
||||
void recorderRef.current?.stop().catch(() => {});
|
||||
}, [clearTimer]);
|
||||
|
||||
// Auto-stop at the cap so a forgotten recording can't blow the
|
||||
// upload size limit.
|
||||
useEffect(() => {
|
||||
if (recording && recordSeconds >= MAX_RECORDING_SECONDS) {
|
||||
stopRecording();
|
||||
}
|
||||
}, [recording, recordSeconds, stopRecording]);
|
||||
|
||||
// ---- Draft send / discard -----------------------------------------
|
||||
|
||||
const sendDraft = useCallback(() => {
|
||||
if (!draft || busy) return;
|
||||
onSendMedia({
|
||||
kind: draft.kind,
|
||||
mediaUrl: draft.mediaUrl,
|
||||
path: draft.path,
|
||||
// Audio takes no caption (Meta rejects it). Everything else: the
|
||||
// trimmed caption, or undefined when blank.
|
||||
caption:
|
||||
draft.kind === "audio" ? undefined : draft.caption.trim() || undefined,
|
||||
filename: draft.kind === "document" ? draft.filename : undefined,
|
||||
replyToId: replyTo?.id,
|
||||
});
|
||||
// The object is now owned by the sent message — clear without GC.
|
||||
setDraft(null);
|
||||
onClearReply?.();
|
||||
}, [draft, busy, onSendMedia, replyTo?.id, onClearReply]);
|
||||
|
||||
// Discard GCs the staged object — it was uploaded but never sent.
|
||||
const discardDraft = useCallback(() => {
|
||||
removeStaged(draft?.path);
|
||||
setDraft(null);
|
||||
}, [draft?.path, removeStaged]);
|
||||
|
||||
const setCaption = useCallback((caption: string) => {
|
||||
setDraft((d) => (d ? { ...d, caption } : d));
|
||||
}, []);
|
||||
|
||||
// ---- Render --------------------------------------------------------
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-800 bg-slate-900 p-3">
|
||||
<div className="border-t border-border bg-card p-3">
|
||||
{replyTo && (
|
||||
<div className="mb-2">
|
||||
<ReplyQuote
|
||||
@ -111,14 +405,119 @@ export function MessageComposer({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hidden file inputs driven by the attach menu. */}
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept={PICKER_ACCEPT.image}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handlePicked("image", e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={videoInputRef}
|
||||
type="file"
|
||||
accept={PICKER_ACCEPT.video}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handlePicked("video", e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={documentInputRef}
|
||||
type="file"
|
||||
accept={PICKER_ACCEPT.document}
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
handlePicked("document", e.target.files?.[0]);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{draft ? (
|
||||
<MediaDraftPreview
|
||||
draft={draft}
|
||||
busy={busy}
|
||||
readOnly={readOnly}
|
||||
onCaptionChange={setCaption}
|
||||
onDiscard={discardDraft}
|
||||
onSend={sendDraft}
|
||||
/>
|
||||
) : recording ? (
|
||||
// Recording bar — replaces the composer while the mic is live.
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted px-4 py-2.5">
|
||||
<span className="flex h-2.5 w-2.5 shrink-0 animate-pulse rounded-full bg-red-500" />
|
||||
<span className="flex-1 text-sm text-foreground">
|
||||
Recording… {formatDuration(recordSeconds)} /{" "}
|
||||
{formatDuration(MAX_RECORDING_SECONDS)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelRecording}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-card hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={stopRecording}
|
||||
className="h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90"
|
||||
title="Stop and attach"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-2">
|
||||
{/* Attach menu — photo / video / document / voice. */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
disabled={inputsDisabled || busy}
|
||||
title={
|
||||
readOnly
|
||||
? "Read-only — your role can't send messages"
|
||||
: inputsDisabled
|
||||
? undefined
|
||||
: "Attach media"
|
||||
}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Paperclip className="h-4 w-4" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="border-border bg-popover">
|
||||
<DropdownMenuItem onClick={() => imageInputRef.current?.click()}>
|
||||
<ImageIcon className="mr-2 h-4 w-4" />
|
||||
Photo
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => videoInputRef.current?.click()}>
|
||||
<Video className="mr-2 h-4 w-4" />
|
||||
Video
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => documentInputRef.current?.click()}>
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Document
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => void startRecording()}>
|
||||
<Mic className="mr-2 h-4 w-4" />
|
||||
Voice note
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<GatedButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
canAct={!readOnly}
|
||||
gateReason="send messages"
|
||||
title={readOnly ? undefined : "Send template"}
|
||||
className="h-9 w-9 shrink-0 p-0 text-slate-400 hover:text-white"
|
||||
className="h-9 w-9 shrink-0 p-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onOpenTemplates}
|
||||
>
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
@ -143,7 +542,7 @@ export function MessageComposer({
|
||||
// The placeholder text also surfaces the read-only state.
|
||||
title={readOnly ? "Read-only — your role can't send messages" : undefined}
|
||||
className={cn(
|
||||
"flex-1 resize-none rounded-xl border border-slate-700 bg-slate-800 px-4 py-2.5 text-sm text-white placeholder-slate-500 outline-none transition-colors focus:border-primary/50",
|
||||
"flex-1 resize-none rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50",
|
||||
(sessionExpired || readOnly) && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
/>
|
||||
@ -159,13 +558,106 @@ export function MessageComposer({
|
||||
<Send className="h-4 w-4" />
|
||||
</GatedButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hint sits outside the flex row so its height doesn't push
|
||||
`items-end` buttons below the textarea. Indented to line up
|
||||
under the textarea left edge (w-9 button + gap-2 = 44px). */}
|
||||
<p className="mt-1 pl-11 text-[10px] text-slate-600">
|
||||
under the textarea left edge. */}
|
||||
{!draft && !recording && (
|
||||
<p className="mt-1 pl-[5.5rem] text-[10px] text-muted-foreground">
|
||||
Type '/' for quick replies
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staged-attachment preview with caption + send/discard. Declared at
|
||||
* module scope (not nested in MessageComposer) so React keeps it mounted
|
||||
* across the parent's re-renders — a nested component would remount the
|
||||
* caption input on every keystroke and drop focus.
|
||||
*/
|
||||
function MediaDraftPreview({
|
||||
draft,
|
||||
busy,
|
||||
readOnly,
|
||||
onCaptionChange,
|
||||
onDiscard,
|
||||
onSend,
|
||||
}: {
|
||||
draft: MediaDraft;
|
||||
busy: boolean;
|
||||
readOnly: boolean;
|
||||
onCaptionChange: (caption: string) => void;
|
||||
onDiscard: () => void;
|
||||
onSend: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-muted/40 p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
{draft.kind === "image" && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={draft.mediaUrl}
|
||||
alt={draft.filename}
|
||||
className="max-h-40 rounded-lg object-cover"
|
||||
/>
|
||||
)}
|
||||
{draft.kind === "video" && (
|
||||
<video src={draft.mediaUrl} controls className="max-h-40 rounded-lg" />
|
||||
)}
|
||||
{draft.kind === "audio" && (
|
||||
<audio src={draft.mediaUrl} controls className="w-full" />
|
||||
)}
|
||||
{draft.kind === "document" && (
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<FileText className="h-5 w-5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{draft.filename}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDiscard}
|
||||
aria-label="Remove attachment"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-end gap-2">
|
||||
{draft.kind !== "audio" && (
|
||||
<input
|
||||
value={draft.caption}
|
||||
maxLength={MEDIA_CAPTION_MAX}
|
||||
onChange={(e) => onCaptionChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
onSend();
|
||||
}
|
||||
}}
|
||||
placeholder="Add a caption…"
|
||||
className="flex-1 rounded-xl border border-border bg-muted px-4 py-2.5 text-sm text-foreground placeholder-muted-foreground outline-none transition-colors focus:border-primary/50"
|
||||
/>
|
||||
)}
|
||||
<GatedButton
|
||||
size="sm"
|
||||
canAct={!readOnly}
|
||||
gateReason="send messages"
|
||||
disabled={busy}
|
||||
onClick={onSend}
|
||||
className={cn(
|
||||
"h-9 w-9 shrink-0 bg-primary p-0 hover:bg-primary/90 disabled:opacity-40",
|
||||
draft.kind === "audio" && "ml-auto",
|
||||
)}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</GatedButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -63,7 +63,7 @@ export function MessageReactions({
|
||||
"inline-flex items-center gap-1 rounded-full border px-1.5 py-0.5 text-[11px] leading-none transition-colors",
|
||||
g.byCurrentUser
|
||||
? "border-primary/60 bg-primary/15 text-primary hover:bg-primary/25"
|
||||
: "border-slate-700 bg-slate-800/80 text-slate-200 hover:bg-slate-700",
|
||||
: "border-border bg-muted/80 text-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<span className="text-sm leading-none">{g.emoji}</span>
|
||||
|
||||
@ -3,6 +3,9 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { usePresence } from "@/hooks/use-presence";
|
||||
import { PresenceDot } from "@/components/presence/presence-dot";
|
||||
import { presenceLabel } from "@/lib/presence";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
Conversation,
|
||||
@ -21,6 +24,8 @@ import {
|
||||
Clock,
|
||||
ArrowLeft,
|
||||
RefreshCw,
|
||||
PanelRightOpen,
|
||||
PanelRightClose,
|
||||
} from "lucide-react";
|
||||
import { format, isToday, isYesterday, differenceInHours } from "date-fns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@ -34,7 +39,12 @@ import {
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { MessageBubble } from "./message-bubble";
|
||||
import { MessageActions } from "./message-actions";
|
||||
import { MessageComposer } from "./message-composer";
|
||||
import {
|
||||
MessageComposer,
|
||||
CHAT_MEDIA_BUCKET,
|
||||
type SendMediaPayload,
|
||||
} from "./message-composer";
|
||||
import { deleteAccountMedia } from "@/lib/storage/upload-media";
|
||||
import { TemplatePicker } from "./template-picker";
|
||||
import { buildReplyPreview } from "./reply-quote";
|
||||
import { toast } from "sonner";
|
||||
@ -87,6 +97,15 @@ interface MessageThreadProps {
|
||||
* working; the button is only rendered when this is provided.
|
||||
*/
|
||||
onRefresh?: () => void;
|
||||
/**
|
||||
* Desktop-only contact-panel toggle. The page owns the open/closed
|
||||
* state (it's the one that renders the sidebar), so the thread just
|
||||
* reflects it and asks the page to flip it. Both optional so existing
|
||||
* callers keep working; the toggle button only renders when
|
||||
* `onToggleContactPanel` is wired up.
|
||||
*/
|
||||
contactPanelOpen?: boolean;
|
||||
onToggleContactPanel?: () => void;
|
||||
}
|
||||
|
||||
function formatDateSeparator(dateStr: string): string {
|
||||
@ -116,7 +135,7 @@ function groupMessagesByDate(messages: Message[]) {
|
||||
const STATUS_OPTIONS: { label: string; value: ConversationStatus; color: string }[] = [
|
||||
{ label: "Open", value: "open", color: "text-primary" },
|
||||
{ label: "Pending", value: "pending", color: "text-amber-400" },
|
||||
{ label: "Closed", value: "closed", color: "text-slate-400" },
|
||||
{ label: "Closed", value: "closed", color: "text-muted-foreground" },
|
||||
];
|
||||
|
||||
/**
|
||||
@ -129,7 +148,7 @@ const STATUS_OPTIONS: { label: string; value: ConversationStatus; color: string
|
||||
* if we ever switch the asset, both spots update together.
|
||||
*/
|
||||
const DOODLE_BG_CLASSES =
|
||||
"bg-slate-950 bg-[url('/inbox-doodle.svg')] bg-repeat";
|
||||
"bg-background bg-[url('/inbox-doodle.svg')] bg-repeat";
|
||||
|
||||
export function MessageThread({
|
||||
conversation,
|
||||
@ -143,8 +162,11 @@ export function MessageThread({
|
||||
onBack,
|
||||
resyncToken = 0,
|
||||
onRefresh,
|
||||
contactPanelOpen,
|
||||
onToggleContactPanel,
|
||||
}: MessageThreadProps) {
|
||||
const { user } = useAuth();
|
||||
const { getPresence, getRow, now } = usePresence();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||
@ -473,6 +495,72 @@ export function MessageThread({
|
||||
[conversation, onNewMessage, onUpdateMessage]
|
||||
);
|
||||
|
||||
const handleSendMedia = useCallback(
|
||||
async (payload: SendMediaPayload) => {
|
||||
if (!conversation) return;
|
||||
|
||||
// Documents show their filename in our own bubble (and to the
|
||||
// recipient as the Meta caption when no caption was typed); other
|
||||
// kinds use the caption as-is. Audio carries no caption.
|
||||
const contentText =
|
||||
payload.kind === "document"
|
||||
? payload.caption || payload.filename || "Document"
|
||||
: payload.caption;
|
||||
|
||||
const tempId = `temp-${Date.now()}`;
|
||||
const optimisticMsg: Message = {
|
||||
id: tempId,
|
||||
conversation_id: conversation.id,
|
||||
sender_type: "agent",
|
||||
content_type: payload.kind,
|
||||
content_text: contentText,
|
||||
media_url: payload.mediaUrl,
|
||||
status: "sending",
|
||||
created_at: new Date().toISOString(),
|
||||
reply_to_message_id: payload.replyToId,
|
||||
};
|
||||
onNewMessage(optimisticMsg);
|
||||
setReplyTo(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/whatsapp/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
conversation_id: conversation.id,
|
||||
message_type: payload.kind,
|
||||
media_url: payload.mediaUrl,
|
||||
content_text: contentText,
|
||||
filename: payload.filename,
|
||||
reply_to_message_id: payload.replyToId,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
|
||||
if (!res.ok) {
|
||||
const reason = data?.error || `HTTP ${res.status}`;
|
||||
console.error("Failed to send media:", reason);
|
||||
toast.error(`Failed to send: ${reason}`);
|
||||
onUpdateMessage(tempId, { status: "failed" });
|
||||
// The upload never reached the recipient — GC the orphaned
|
||||
// object rather than leaving it in the public bucket forever.
|
||||
void deleteAccountMedia(CHAT_MEDIA_BUCKET, payload.path).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdateMessage(tempId, { status: "sent" });
|
||||
} catch (err) {
|
||||
console.error("Failed to send media:", err);
|
||||
const reason = err instanceof Error ? err.message : "network error";
|
||||
toast.error(`Failed to send: ${reason}`);
|
||||
onUpdateMessage(tempId, { status: "failed" });
|
||||
void deleteAccountMedia(CHAT_MEDIA_BUCKET, payload.path).catch(() => {});
|
||||
}
|
||||
},
|
||||
[conversation, onNewMessage, onUpdateMessage],
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
async (status: ConversationStatus) => {
|
||||
if (!conversation) return;
|
||||
@ -696,13 +784,13 @@ export function MessageThread({
|
||||
if (!conversation || !contact) {
|
||||
return (
|
||||
<div className={cn("flex flex-1 flex-col items-center justify-center", DOODLE_BG_CLASSES)}>
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-800">
|
||||
<MessageSquare className="h-8 w-8 text-slate-600" />
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
||||
<MessageSquare className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-sm font-medium text-slate-400">
|
||||
<h3 className="mt-4 text-sm font-medium text-muted-foreground">
|
||||
Select a conversation
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-slate-600">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Choose a conversation from the left to start messaging
|
||||
</p>
|
||||
</div>
|
||||
@ -721,10 +809,18 @@ export function MessageThread({
|
||||
: "Assign";
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-1 flex-col", DOODLE_BG_CLASSES)}>
|
||||
{/* Header — solid bg-slate-900 sits on top of the doodle so the
|
||||
// `min-w-0` is load-bearing: the page already puts min-w-0 on the
|
||||
// thread's flex *wrapper* (issue #165), but this root keeps the
|
||||
// default `min-width: auto`, so a single wide message (long unbroken
|
||||
// URL/word) expands the whole thread past its flex share and the chat
|
||||
// paints on top of the contact sidebar at lg+ — outgoing bubbles get
|
||||
// clipped and the hover toolbar overlaps the Tags panel. Letting the
|
||||
// root shrink lets the bubbles' break-words / max-w caps apply.
|
||||
// Issue #257.
|
||||
<div className={cn("flex min-w-0 flex-1 flex-col", DOODLE_BG_CLASSES)}>
|
||||
{/* Header — solid card surface sits on top of the doodle so the
|
||||
name/avatar/dropdowns stay legible. */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-slate-800 bg-slate-900 px-3 py-3 sm:px-4">
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border bg-card px-3 py-3 sm:px-4">
|
||||
<div className="flex min-w-0 items-center gap-2 sm:gap-3">
|
||||
{/* Back-to-list button — mobile only. Hidden on lg+ where the
|
||||
conversation list is always visible next to the thread. */}
|
||||
@ -733,24 +829,24 @@ export function MessageThread({
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="Back to conversations"
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-slate-300 hover:bg-slate-800 hover:text-white lg:hidden"
|
||||
className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground lg:hidden"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
|
||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium text-foreground">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold text-white">{displayName}</h2>
|
||||
<p className="truncate text-xs text-slate-400">{contact.phone}</p>
|
||||
<h2 className="truncate text-sm font-semibold text-foreground">{displayName}</h2>
|
||||
<p className="truncate text-xs text-muted-foreground">{contact.phone}</p>
|
||||
</div>
|
||||
{/* Session timer badge — hidden on the narrowest phones so
|
||||
the name + back arrow keep their room. */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"ml-1 hidden gap-1 border-slate-700 text-[10px] sm:inline-flex sm:ml-2",
|
||||
"ml-1 hidden gap-1 border-border text-[10px] sm:inline-flex sm:ml-2",
|
||||
sessionInfo.expired ? "text-red-400" : "text-primary"
|
||||
)}
|
||||
>
|
||||
@ -760,6 +856,33 @@ export function MessageThread({
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Contact-panel toggle — desktop only. The contact sidebar
|
||||
eats a chunk of horizontal width that crowds the thread on
|
||||
smaller laptops; this lets agents reclaim it when they just
|
||||
want to read and reply. Hidden on mobile, where the sidebar
|
||||
never renders as a permanent panel anyway. Issue #258. */}
|
||||
{onToggleContactPanel && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleContactPanel}
|
||||
aria-label={
|
||||
contactPanelOpen ? "Hide contact panel" : "Show contact panel"
|
||||
}
|
||||
aria-pressed={contactPanelOpen}
|
||||
title={contactPanelOpen ? "Hide contact" : "Show contact"}
|
||||
className={cn(
|
||||
"hidden h-7 w-7 items-center justify-center rounded-md transition-colors hover:bg-muted hover:text-foreground lg:inline-flex",
|
||||
contactPanelOpen ? "text-primary" : "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{contactPanelOpen ? (
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
) : (
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Manual refresh — forces a refetch of the messages + the
|
||||
conversation list (the parent bumps its resyncToken). Useful
|
||||
when realtime missed an event or the agent just wants to be
|
||||
@ -773,7 +896,7 @@ export function MessageThread({
|
||||
aria-label="Refresh conversation"
|
||||
title="Refresh"
|
||||
className={cn(
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-slate-800 hover:text-white disabled:opacity-60",
|
||||
"inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-60",
|
||||
)}
|
||||
>
|
||||
<RefreshCw
|
||||
@ -785,15 +908,15 @@ export function MessageThread({
|
||||
{/* Status dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={cn(
|
||||
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-slate-800",
|
||||
currentStatus?.color ?? "text-slate-400"
|
||||
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
|
||||
currentStatus?.color ?? "text-muted-foreground"
|
||||
)}>
|
||||
{currentStatus?.label ?? "Status"}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="border-slate-700 bg-slate-800"
|
||||
className="border-border bg-popover"
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
@ -811,8 +934,8 @@ export function MessageThread({
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-slate-800",
|
||||
assignedAgentId ? "text-primary" : "text-slate-400"
|
||||
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-muted",
|
||||
assignedAgentId ? "text-primary" : "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<UserPlus className="h-3 w-3" />
|
||||
@ -821,24 +944,34 @@ export function MessageThread({
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="border-slate-700 bg-slate-800"
|
||||
className="border-border bg-popover"
|
||||
>
|
||||
{profiles.length === 0 ? (
|
||||
<DropdownMenuItem disabled className="text-sm text-slate-500">
|
||||
<DropdownMenuItem disabled className="text-sm text-muted-foreground">
|
||||
No teammates available
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
profiles.map((p) => {
|
||||
const isSelected = p.user_id === assignedAgentId;
|
||||
const presence = getPresence(p.user_id);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
onClick={() => handleAssignChange(p.user_id)}
|
||||
className={cn(
|
||||
"text-sm",
|
||||
isSelected ? "text-primary" : "text-slate-300"
|
||||
isSelected ? "text-primary" : "text-popover-foreground"
|
||||
)}
|
||||
>
|
||||
<PresenceDot
|
||||
status={presence}
|
||||
label={presenceLabel(
|
||||
presence,
|
||||
getRow(p.user_id)?.last_seen_at ?? null,
|
||||
now
|
||||
)}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span className="flex-1">
|
||||
{p.full_name}
|
||||
{p.user_id === user?.id ? " (me)" : ""}
|
||||
@ -850,10 +983,10 @@ export function MessageThread({
|
||||
)}
|
||||
{assignedAgentId && (
|
||||
<>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuSeparator className="bg-border" />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleAssignChange(null)}
|
||||
className="text-sm text-slate-400"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
Unassign
|
||||
</DropdownMenuItem>
|
||||
@ -872,8 +1005,8 @@ export function MessageThread({
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-sm text-slate-500">No messages yet</p>
|
||||
<p className="text-xs text-slate-600">
|
||||
<p className="text-sm text-muted-foreground">No messages yet</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send a template to start the conversation
|
||||
</p>
|
||||
</div>
|
||||
@ -883,7 +1016,7 @@ export function MessageThread({
|
||||
<div key={group.date}>
|
||||
{/* Date separator */}
|
||||
<div className="mb-4 flex items-center justify-center">
|
||||
<span className="rounded-full bg-slate-800 px-3 py-1 text-[10px] font-medium text-slate-400">
|
||||
<span className="rounded-full bg-muted px-3 py-1 text-[10px] font-medium text-muted-foreground">
|
||||
{formatDateSeparator(group.date)}
|
||||
</span>
|
||||
</div>
|
||||
@ -942,6 +1075,7 @@ export function MessageThread({
|
||||
conversationId={conversation.id}
|
||||
sessionExpired={sessionInfo.expired}
|
||||
onSend={handleSend}
|
||||
onSendMedia={handleSendMedia}
|
||||
onOpenTemplates={handleOpenTemplates}
|
||||
replyTo={replyTo}
|
||||
onClearReply={() => setReplyTo(null)}
|
||||
|
||||
@ -14,25 +14,38 @@ interface ReplyQuoteProps {
|
||||
/** Present → renders the composer-chip variant with an X button. Absent →
|
||||
* renders the embedded-in-bubble variant. */
|
||||
onDismiss?: () => void;
|
||||
/** True when embedded inside an outbound (primary-filled) bubble, so the
|
||||
* quote must read against the primary surface rather than the neutral
|
||||
* foreground — otherwise it goes low-contrast in light mode. */
|
||||
onPrimary?: boolean;
|
||||
}
|
||||
|
||||
export function ReplyQuote({
|
||||
authorLabel,
|
||||
preview,
|
||||
onDismiss,
|
||||
onPrimary = false,
|
||||
}: ReplyQuoteProps) {
|
||||
const isChip = !!onDismiss;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start gap-2 border-l-2 border-primary px-2 py-1",
|
||||
"flex items-start gap-2 border-l-2 px-2 py-1",
|
||||
onPrimary ? "border-primary-foreground/50" : "border-primary",
|
||||
isChip
|
||||
? "rounded-md bg-slate-800/80"
|
||||
: "mb-1.5 rounded-md bg-black/20",
|
||||
? "rounded-md bg-muted/80"
|
||||
: onPrimary
|
||||
? "mb-1.5 rounded-md bg-primary-foreground/15"
|
||||
: "mb-1.5 rounded-md bg-background/20",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1 overflow-hidden">
|
||||
<div className="truncate text-[11px] font-medium text-primary">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-[11px] font-medium",
|
||||
onPrimary ? "text-primary-foreground" : "text-primary",
|
||||
)}
|
||||
>
|
||||
{authorLabel}
|
||||
</div>
|
||||
{/* Wrap the preview instead of truncating to a single line.
|
||||
@ -42,7 +55,7 @@ export function ReplyQuote({
|
||||
* layout wider, shoving the contact sidebar off-screen.
|
||||
* `break-words` also wraps long URLs that have no whitespace
|
||||
* to break on. Issue #165. */}
|
||||
<div className="whitespace-pre-wrap break-words text-xs text-slate-200/80">
|
||||
<div className="whitespace-pre-wrap break-words text-xs text-foreground/80">
|
||||
{preview}
|
||||
</div>
|
||||
</div>
|
||||
@ -51,7 +64,7 @@ export function ReplyQuote({
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label="Cancel reply"
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||
className="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@ -104,10 +104,13 @@ export function TemplatePicker({
|
||||
return;
|
||||
}
|
||||
|
||||
// Scope by RLS (message_templates_select → is_account_member), NOT by
|
||||
// user_id. Templates are account-owned, so filtering on the caller's
|
||||
// user_id hid templates that a teammate created — leaving them unable
|
||||
// to send approved templates in a shared account.
|
||||
const { data, error } = await supabase
|
||||
.from("message_templates")
|
||||
.select("*")
|
||||
.eq("user_id", user.id)
|
||||
.eq("status", "APPROVED")
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
@ -183,13 +186,13 @@ export function TemplatePicker({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="border-slate-700 bg-slate-900 sm:max-w-lg">
|
||||
<DialogContent className="border-border bg-popover sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
|
||||
<LayoutTemplate className="h-4 w-4 text-primary" />
|
||||
{selected ? selected.name : "Send template"}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{selected
|
||||
? "Fill in the placeholders to render this template. Meta requires every variable to be set."
|
||||
: "Pick an approved WhatsApp template to send to this contact."}
|
||||
@ -203,9 +206,9 @@ export function TemplatePicker({
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<div className="rounded-md border border-slate-800 bg-slate-950/50 p-6 text-center">
|
||||
<p className="text-sm text-slate-300">No approved templates</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
<div className="rounded-md border border-border bg-background/50 p-6 text-center">
|
||||
<p className="text-sm text-popover-foreground">No approved templates</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Approve a template in Meta WhatsApp Manager, then sync it
|
||||
from Settings → Templates.
|
||||
</p>
|
||||
@ -216,28 +219,28 @@ export function TemplatePicker({
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => pickTemplate(t)}
|
||||
className="w-full rounded-md border border-slate-800 bg-slate-950/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-slate-900"
|
||||
className="w-full rounded-md border border-border bg-background/50 p-3 text-left transition-colors hover:border-primary/40 hover:bg-popover"
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
<p className="truncate text-sm font-medium text-popover-foreground">
|
||||
{t.name}
|
||||
</p>
|
||||
<Badge className="border border-primary/30 bg-primary/20 text-[10px] text-primary">
|
||||
{t.category}
|
||||
</Badge>
|
||||
{t.language && (
|
||||
<span className="text-[10px] uppercase text-slate-500">
|
||||
<span className="text-[10px] uppercase text-muted-foreground">
|
||||
{t.language}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 line-clamp-2 text-xs text-slate-400">
|
||||
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
||||
{t.body_text}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0 text-slate-500" />
|
||||
<ChevronRight className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
@ -245,33 +248,33 @@ export function TemplatePicker({
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-slate-800 bg-slate-950/50 p-3">
|
||||
<p className="mb-1 text-xs text-slate-400">Preview</p>
|
||||
<p className="whitespace-pre-wrap text-sm text-slate-200">
|
||||
<div className="rounded-md border border-border bg-background/50 p-3">
|
||||
<p className="mb-1 text-xs text-muted-foreground">Preview</p>
|
||||
<p className="whitespace-pre-wrap text-sm text-popover-foreground">
|
||||
{renderBodyPreview(selected.body_text, params)}
|
||||
</p>
|
||||
{selected.footer_text && (
|
||||
<p className="mt-2 text-xs italic text-slate-500">
|
||||
<p className="mt-2 text-xs italic text-muted-foreground">
|
||||
{selected.footer_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{slots && slots.headerVarCount > 0 && (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">
|
||||
<Label className="text-xs text-popover-foreground">
|
||||
{`Header {{1}}`}
|
||||
</Label>
|
||||
<Input
|
||||
value={headerText}
|
||||
onChange={(e) => setHeaderText(e.target.value)}
|
||||
placeholder="Value for the header variable"
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{slots?.bodyVars.map((v, i) => (
|
||||
<div key={v} className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">{`Body {{${v}}}`}</Label>
|
||||
<Label className="text-xs text-popover-foreground">{`Body {{${v}}}`}</Label>
|
||||
<Input
|
||||
value={params[i] ?? ""}
|
||||
onChange={(e) => {
|
||||
@ -280,13 +283,13 @@ export function TemplatePicker({
|
||||
setParams(next);
|
||||
}}
|
||||
placeholder={`Value for {{${v}}}`}
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{slots?.urlButtonSlots.map((slot) => (
|
||||
<div key={slot.index} className="space-y-1">
|
||||
<Label className="text-xs text-slate-300">
|
||||
<Label className="text-xs text-popover-foreground">
|
||||
{`URL button "${slot.text}" — value for `}{`{{1}}`}
|
||||
</Label>
|
||||
<Input
|
||||
@ -298,9 +301,9 @@ export function TemplatePicker({
|
||||
}))
|
||||
}
|
||||
placeholder="URL suffix value"
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
||||
className="border-border bg-muted text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500 break-all">
|
||||
<p className="text-[10px] text-muted-foreground break-all">
|
||||
Final URL: {slot.url.replace(/\{\{1\}\}/g, buttonParams[slot.index] || "{{1}}")}
|
||||
</p>
|
||||
</div>
|
||||
@ -314,7 +317,7 @@ export function TemplatePicker({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={resetSelection}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-popover-foreground hover:bg-muted"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
@ -331,7 +334,7 @@ export function TemplatePicker({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-popover-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ModeToggle } from "@/components/layout/mode-toggle";
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
"/dashboard": "Dashboard",
|
||||
@ -53,25 +54,28 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
||||
"U";
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-slate-800 bg-slate-950 px-4 lg:px-6">
|
||||
<header className="flex h-14 shrink-0 items-center justify-between gap-3 border-b border-border bg-background px-4 lg:px-6">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{/* Hamburger — mobile only. 44×44 hit target per Apple HIG. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenSidebar}
|
||||
aria-label="Open menu"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md text-slate-300 transition-colors hover:bg-slate-800 hover:text-white lg:hidden"
|
||||
className="flex h-10 w-10 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground lg:hidden"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<h1 className="truncate text-base font-semibold text-white sm:text-lg">
|
||||
<h1 className="truncate text-base font-semibold text-foreground sm:text-lg">
|
||||
{title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
<ModeToggle />
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className="flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-slate-800/70 focus:bg-slate-800/70 focus:outline-none data-popup-open:bg-slate-800/70 sm:gap-3 sm:pl-1 sm:pr-3"
|
||||
className="flex items-center gap-2 rounded-md px-1 py-1 transition-colors hover:bg-muted/70 focus:bg-muted/70 focus:outline-none data-popup-open:bg-muted/70 sm:gap-3 sm:pl-1 sm:pr-3"
|
||||
aria-label="Open account menu"
|
||||
>
|
||||
<Avatar className="size-8">
|
||||
@ -85,29 +89,29 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
||||
{initial}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="hidden text-sm font-medium text-white sm:inline">
|
||||
<span className="hidden text-sm font-medium text-foreground sm:inline">
|
||||
{profile?.full_name ?? "User"}
|
||||
</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
className="min-w-56 bg-slate-900 text-slate-100 ring-slate-700"
|
||||
className="min-w-56 bg-popover text-popover-foreground ring-border"
|
||||
>
|
||||
<div className="px-2 py-1.5">
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{profile?.full_name ?? "User"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-slate-400">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profile?.email ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
<DropdownMenuSeparator className="bg-slate-800" />
|
||||
<DropdownMenuSeparator className="bg-border" />
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
href="/settings?tab=profile"
|
||||
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
@ -118,23 +122,24 @@ export function Header({ onOpenSidebar }: HeaderProps) {
|
||||
render={
|
||||
<Link
|
||||
href="/settings?tab=whatsapp"
|
||||
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-800" />
|
||||
<DropdownMenuSeparator className="bg-border" />
|
||||
<DropdownMenuItem
|
||||
onClick={signOut}
|
||||
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
37
src/components/layout/mode-toggle.tsx
Normal file
37
src/components/layout/mode-toggle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -54,14 +54,14 @@ const ROLE_CHIP: Record<
|
||||
label: "Agent",
|
||||
// Neutral slate: the operational default.
|
||||
className:
|
||||
"border-slate-700 bg-slate-800 text-slate-300",
|
||||
"border-border bg-muted text-foreground",
|
||||
},
|
||||
viewer: {
|
||||
icon: User,
|
||||
label: "Viewer",
|
||||
// Muted slate: read-only role; visually quieter than agent.
|
||||
className:
|
||||
"border-slate-800 bg-slate-900 text-slate-500",
|
||||
"border-border bg-card text-muted-foreground",
|
||||
},
|
||||
};
|
||||
import {
|
||||
@ -161,7 +161,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
aria-label="Close menu"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"fixed inset-0 z-30 bg-slate-950/70 backdrop-blur-sm transition-opacity lg:hidden",
|
||||
"fixed inset-0 z-30 bg-background/70 backdrop-blur-sm transition-opacity lg:hidden",
|
||||
open
|
||||
? "pointer-events-auto opacity-100"
|
||||
: "pointer-events-none opacity-0",
|
||||
@ -171,7 +171,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
<aside
|
||||
className={cn(
|
||||
// Mobile: fixed drawer that slides in from the left.
|
||||
"fixed inset-y-0 left-0 z-40 flex h-full w-64 flex-col border-r border-slate-800 bg-slate-900",
|
||||
"fixed inset-y-0 left-0 z-40 flex h-full w-64 flex-col border-r border-border bg-card",
|
||||
"transition-transform duration-200 ease-out will-change-transform",
|
||||
open ? "translate-x-0" : "-translate-x-full",
|
||||
// Desktop: static, always visible — reset all the mobile framing.
|
||||
@ -181,12 +181,12 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
>
|
||||
{/* Logo row. On mobile we put a close button here; on desktop the
|
||||
close button is hidden since the sidebar is always-visible. */}
|
||||
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-slate-800 px-4">
|
||||
<div className="flex h-14 shrink-0 items-center justify-between gap-2 border-b border-border px-4">
|
||||
<Link href="/dashboard" className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
CRM Template for WhatsApp
|
||||
</span>
|
||||
</Link>
|
||||
@ -194,7 +194,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close menu"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-slate-400 hover:bg-slate-800 hover:text-white lg:hidden"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground lg:hidden"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
@ -227,7 +227,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white",
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
@ -263,7 +263,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="my-4 border-t border-slate-800" />
|
||||
<div className="my-4 border-t border-border" />
|
||||
|
||||
<ul className="flex flex-col gap-1">
|
||||
{bottomNavItems.map((item) => {
|
||||
@ -276,7 +276,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors lg:py-2",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white",
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
@ -289,7 +289,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className="shrink-0 border-t border-slate-800 p-3">
|
||||
<div className="shrink-0 border-t border-border p-3">
|
||||
{/* Account name display — surfaced only when the account
|
||||
name differs from the user's own name (see
|
||||
`showAccountStrip`). For a default solo account the two
|
||||
@ -297,7 +297,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
below; for renamed or shared accounts it tells the user
|
||||
which account they're acting in. */}
|
||||
{showAccountStrip && account?.name ? (
|
||||
<div className="mb-2 flex items-center gap-2 px-3 text-xs text-slate-500">
|
||||
<div className="mb-2 flex items-center gap-2 px-3 text-xs text-muted-foreground">
|
||||
<UsersRound className="size-3.5 shrink-0" />
|
||||
{/* `title=` exposes the full name on hover when it
|
||||
gets truncated (long account names + narrow
|
||||
@ -326,7 +326,7 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
</div>
|
||||
) : null}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-slate-800/60 focus:bg-slate-800/60 focus:outline-none data-popup-open:bg-slate-800/60">
|
||||
<DropdownMenuTrigger className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors hover:bg-muted/60 focus:bg-muted/60 focus:outline-none data-popup-open:bg-muted/60">
|
||||
<Avatar className="size-8 shrink-0">
|
||||
{profile?.avatar_url ? (
|
||||
<AvatarImage
|
||||
@ -341,10 +341,10 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{profile?.full_name ?? "User"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-slate-400">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{profile?.email ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
@ -353,14 +353,14 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
align="end"
|
||||
side="top"
|
||||
sideOffset={6}
|
||||
className="min-w-56 bg-slate-900 text-slate-100 ring-slate-700"
|
||||
className="min-w-56 bg-popover text-popover-foreground ring-border"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
render={
|
||||
<Link
|
||||
href="/settings?tab=profile"
|
||||
onClick={onClose}
|
||||
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
@ -372,17 +372,17 @@ export function Sidebar({ open = false, onClose }: SidebarProps) {
|
||||
<Link
|
||||
href="/settings?tab=whatsapp"
|
||||
onClick={onClose}
|
||||
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-800" />
|
||||
<DropdownMenuSeparator className="bg-border" />
|
||||
<DropdownMenuItem
|
||||
onClick={signOut}
|
||||
className="text-slate-200 focus:bg-slate-800 focus:text-white"
|
||||
className="text-popover-foreground focus:bg-accent focus:text-accent-foreground"
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
Sign out
|
||||
|
||||
@ -39,10 +39,10 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
|
||||
e.stopPropagation();
|
||||
onEdit(deal);
|
||||
}}
|
||||
className={`group relative w-full cursor-pointer rounded-xl border border-slate-700/50 bg-slate-800/70 pl-4 pr-3 py-3 text-left shadow-sm transition-all ${
|
||||
className={`group relative w-full cursor-pointer rounded-xl border border-border/50 bg-muted/70 pl-4 pr-3 py-3 text-left shadow-sm transition-all ${
|
||||
isOverlay
|
||||
? "shadow-xl"
|
||||
: "hover:-translate-y-0.5 hover:border-slate-600 hover:bg-slate-800 hover:shadow-lg"
|
||||
: "hover:-translate-y-0.5 hover:border-border hover:bg-muted hover:shadow-lg"
|
||||
}`}
|
||||
>
|
||||
{/* 4px left accent bar using stage color */}
|
||||
@ -53,7 +53,7 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
|
||||
/>
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="flex-1 text-sm font-semibold leading-snug text-white break-words">
|
||||
<h4 className="flex-1 text-sm font-semibold leading-snug text-foreground break-words">
|
||||
{deal.title}
|
||||
</h4>
|
||||
{deal.status === "won" && (
|
||||
@ -72,10 +72,10 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
|
||||
|
||||
{/* Contact row */}
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-slate-700 text-[10px] font-semibold text-slate-200">
|
||||
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-foreground">
|
||||
{initials(deal.contact?.name, deal.contact?.phone)}
|
||||
</span>
|
||||
<span className="truncate text-xs text-slate-400">{contactLabel}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{contactLabel}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
@ -83,7 +83,7 @@ export function DealCard({ deal, stage, onEdit, isOverlay }: DealCardProps) {
|
||||
{formatCurrency(deal.value, deal.currency)}
|
||||
</span>
|
||||
{deal.expected_close_date && (
|
||||
<span className="flex items-center gap-1 text-[11px] text-slate-500">
|
||||
<span className="flex items-center gap-1 text-[11px] text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{formatDate(deal.expected_close_date)}
|
||||
</span>
|
||||
|
||||
@ -247,32 +247,32 @@ export function DealForm({
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
side="right"
|
||||
className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg w-full p-0"
|
||||
className="bg-popover border-border text-popover-foreground sm:max-w-lg w-full p-0"
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<SheetHeader className="border-b border-slate-700/50 p-4">
|
||||
<SheetTitle className="text-white">
|
||||
<SheetHeader className="border-b border-border/50 p-4">
|
||||
<SheetTitle className="text-popover-foreground">
|
||||
{deal ? "Edit Deal" : "New Deal"}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Title</Label>
|
||||
<Label className="text-muted-foreground">Title</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Deal title"
|
||||
className="border-slate-700 bg-slate-800 text-white"
|
||||
className="border-border bg-muted text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Contact</Label>
|
||||
<Label className="text-muted-foreground">Contact</Label>
|
||||
<select
|
||||
value={contactId}
|
||||
onChange={(e) => setContactId(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
>
|
||||
<option value="">Select a contact</option>
|
||||
{contacts.map((c) => (
|
||||
@ -295,24 +295,24 @@ export function DealForm({
|
||||
|
||||
<div className="grid grid-cols-[1fr_110px] gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Value</Label>
|
||||
<Label className="text-muted-foreground">Value</Label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-500" />
|
||||
<DollarSign className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="0"
|
||||
className="border-slate-700 bg-slate-800 pl-7 text-white"
|
||||
className="border-border bg-muted pl-7 text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Currency</Label>
|
||||
<Label className="text-muted-foreground">Currency</Label>
|
||||
<select
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary"
|
||||
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary"
|
||||
>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c.code} value={c.code}>
|
||||
@ -324,21 +324,21 @@ export function DealForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Expected Close Date</Label>
|
||||
<Label className="text-muted-foreground">Expected Close Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expectedCloseDate}
|
||||
onChange={(e) => setExpectedCloseDate(e.target.value)}
|
||||
className="border-slate-700 bg-slate-800 text-white"
|
||||
className="border-border bg-muted text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Stage</Label>
|
||||
<Label className="text-muted-foreground">Stage</Label>
|
||||
<select
|
||||
value={stageId}
|
||||
onChange={(e) => setStageId(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary"
|
||||
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary"
|
||||
>
|
||||
{stages.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
@ -349,11 +349,11 @@ export function DealForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Assigned To</Label>
|
||||
<Label className="text-muted-foreground">Assigned To</Label>
|
||||
<select
|
||||
value={assignedTo}
|
||||
onChange={(e) => setAssignedTo(e.target.value)}
|
||||
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary"
|
||||
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary"
|
||||
>
|
||||
<option value="">Unassigned</option>
|
||||
{profiles.map((p) => (
|
||||
@ -365,18 +365,18 @@ export function DealForm({
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Notes</Label>
|
||||
<Label className="text-muted-foreground">Notes</Label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add notes..."
|
||||
className="min-h-[100px] border-slate-700 bg-slate-800 text-white"
|
||||
className="min-h-[100px] border-border bg-muted text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{deal && (
|
||||
<div className="space-y-2 rounded-lg border border-slate-700 bg-slate-900/50 p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-slate-400">
|
||||
<div className="space-y-2 rounded-lg border border-border bg-muted/50 p-3">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Status
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
@ -417,7 +417,7 @@ export function DealForm({
|
||||
variant="ghost"
|
||||
onClick={() => handleStatusChange("open")}
|
||||
disabled={!!statusAction}
|
||||
className="w-full text-slate-400 hover:text-white"
|
||||
className="w-full text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Reopen deal
|
||||
</Button>
|
||||
@ -426,12 +426,12 @@ export function DealForm({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-slate-700/50 bg-slate-900/80 p-4">
|
||||
<div className="border-t border-border/50 bg-popover/80 p-4">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex-1 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||||
className="flex-1 border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@ -453,7 +453,7 @@ export function DealForm({
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(false)}
|
||||
disabled={deleting}
|
||||
className="rounded px-2 py-1 text-slate-300 hover:bg-slate-800"
|
||||
className="rounded px-2 py-1 text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@ -93,9 +93,9 @@ export function PipelineAnalytics({ stages, deals }: PipelineAnalyticsProps) {
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<div className="grid grid-cols-2 gap-3 rounded-xl border border-slate-800 bg-slate-900/60 p-4 sm:grid-cols-3 xl:grid-cols-6">
|
||||
<div className="grid grid-cols-2 gap-3 rounded-xl border border-border bg-card/60 p-4 sm:grid-cols-3 xl:grid-cols-6">
|
||||
<Metric
|
||||
icon={<BarChart3 className="h-4 w-4 text-slate-400" />}
|
||||
icon={<BarChart3 className="h-4 w-4 text-muted-foreground" />}
|
||||
label="Total Deals"
|
||||
value={String(stats.totalCount)}
|
||||
tooltip="Count of every deal in this pipeline that isn't marked as Lost. Won deals are still included."
|
||||
@ -147,8 +147,8 @@ function Metric({
|
||||
tooltip: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg bg-slate-800/50 p-3">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-slate-400">
|
||||
<div className="rounded-lg bg-muted/50 p-3">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
<Tooltip>
|
||||
@ -157,7 +157,7 @@ function Metric({
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`How ${label} is calculated`}
|
||||
className="ml-auto text-slate-500 hover:text-slate-300 focus:outline-none"
|
||||
className="ml-auto text-muted-foreground hover:text-foreground focus:outline-none"
|
||||
/>
|
||||
}
|
||||
>
|
||||
@ -168,7 +168,7 @@ function Metric({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="mt-1 text-base font-semibold text-white">{value}</p>
|
||||
<p className="mt-1 text-base font-semibold text-foreground">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -164,7 +164,7 @@ export function PipelineBoard({
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.pipeline-scroll {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(51 65 85) transparent;
|
||||
scrollbar-color: var(--border) transparent;
|
||||
}
|
||||
.pipeline-scroll::-webkit-scrollbar {
|
||||
height: 8px;
|
||||
@ -173,11 +173,11 @@ export function PipelineBoard({
|
||||
background: transparent;
|
||||
}
|
||||
.pipeline-scroll::-webkit-scrollbar-thumb {
|
||||
background-color: rgb(51 65 85);
|
||||
background-color: var(--border);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.pipeline-scroll::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgb(71 85 105);
|
||||
background-color: var(--muted-foreground);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
@ -209,21 +209,21 @@ function StageColumn({
|
||||
// restore the flex-1 share-the-row behavior. The droppable ref is
|
||||
// on the inner messages region below — intentionally NOT here, so
|
||||
// a drag over the column header doesn't highlight the whole column.
|
||||
<div className="flex w-[85vw] min-w-[260px] max-w-[320px] shrink-0 snap-start flex-col rounded-xl border border-slate-800 bg-slate-900/60 p-4 lg:w-auto lg:max-w-none lg:flex-1 lg:basis-[260px] lg:shrink lg:snap-none">
|
||||
<div className="flex w-[85vw] min-w-[260px] max-w-[320px] shrink-0 snap-start flex-col rounded-xl border border-border bg-card/60 p-4 lg:w-auto lg:max-w-none lg:flex-1 lg:basis-[260px] lg:shrink lg:snap-none">
|
||||
{/* 3px colored top border — sits above the column's padding */}
|
||||
<div
|
||||
className="-mx-4 -mt-4 h-[3px] rounded-t-xl"
|
||||
style={{ backgroundColor: stage.color }}
|
||||
/>
|
||||
<div className="flex items-center justify-between pt-3">
|
||||
<h3 className="truncate text-sm font-semibold text-white">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground">
|
||||
{stage.name}
|
||||
</h3>
|
||||
<span className="shrink-0 rounded-full bg-slate-800 px-2 py-0.5 text-[11px] font-medium text-slate-300">
|
||||
<span className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
{deals.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatCurrency(totalValue, currency)}
|
||||
</p>
|
||||
|
||||
@ -236,7 +236,7 @@ function StageColumn({
|
||||
}`}
|
||||
>
|
||||
{deals.length === 0 ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-slate-700 py-10 text-xs text-slate-500">
|
||||
<div className="flex flex-1 items-center justify-center rounded-lg border-2 border-dashed border-border py-10 text-xs text-muted-foreground">
|
||||
Drop a deal here
|
||||
</div>
|
||||
) : (
|
||||
@ -255,7 +255,7 @@ function StageColumn({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onAddDeal(stage.id)}
|
||||
className="mt-3 w-full justify-start border border-dashed border-slate-700 bg-transparent text-slate-400 hover:border-slate-600 hover:bg-slate-800 hover:text-white"
|
||||
className="mt-3 w-full justify-start border border-dashed border-border bg-transparent text-muted-foreground hover:border-border hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add Deal
|
||||
|
||||
@ -199,9 +199,9 @@ export function PipelineSettings({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700 max-h-[85vh] overflow-y-auto">
|
||||
<DialogContent className="sm:max-w-md bg-popover border-border max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Manage Pipeline</DialogTitle>
|
||||
<DialogTitle className="text-popover-foreground">Manage Pipeline</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{showDeleteConfirm ? (
|
||||
@ -212,7 +212,7 @@ export function PipelineSettings({
|
||||
<p className="text-sm font-medium text-red-400">
|
||||
Delete Pipeline
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
This will archive all deals in this pipeline. This cannot be
|
||||
undone.
|
||||
</p>
|
||||
@ -222,7 +222,7 @@ export function PipelineSettings({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||||
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@ -239,16 +239,16 @@ export function PipelineSettings({
|
||||
<>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Pipeline Name</Label>
|
||||
<Label className="text-muted-foreground">Pipeline Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="border-slate-700 bg-slate-800 text-white"
|
||||
className="border-border bg-muted text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Stages</Label>
|
||||
<Label className="text-muted-foreground">Stages</Label>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
@ -292,7 +292,9 @@ export function PipelineSettings({
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor:
|
||||
newStageColor === color ? "white" : "transparent",
|
||||
newStageColor === color
|
||||
? "var(--foreground)"
|
||||
: "transparent",
|
||||
}}
|
||||
aria-label={`Pick color ${color}`}
|
||||
/>
|
||||
@ -303,7 +305,7 @@ export function PipelineSettings({
|
||||
value={newStageName}
|
||||
onChange={(e) => setNewStageName(e.target.value)}
|
||||
placeholder="New stage name"
|
||||
className="border-slate-700 bg-slate-800 text-sm text-white"
|
||||
className="border-border bg-muted text-sm text-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddStage();
|
||||
}}
|
||||
@ -313,7 +315,7 @@ export function PipelineSettings({
|
||||
size="sm"
|
||||
onClick={handleAddStage}
|
||||
disabled={!newStageName.trim()}
|
||||
className="shrink-0 border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||||
className="shrink-0 border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Add
|
||||
@ -324,14 +326,14 @@ export function PipelineSettings({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onCreateNewPipeline}
|
||||
className="w-full border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||||
className="w-full border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
Create a new pipeline
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="border-slate-700 bg-slate-900/50">
|
||||
<DialogFooter className="border-border bg-popover/50">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
@ -342,7 +344,7 @@ export function PipelineSettings({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-slate-700 bg-transparent text-slate-300 hover:bg-slate-800"
|
||||
className="border-border bg-transparent text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@ -387,13 +389,13 @@ function SortableStageRow({
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800 p-2"
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-muted p-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="cursor-grab touch-none text-slate-500 hover:text-slate-300 active:cursor-grabbing"
|
||||
className="cursor-grab touch-none text-muted-foreground hover:text-foreground active:cursor-grabbing"
|
||||
aria-label="Drag to reorder"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
@ -402,13 +404,13 @@ function SortableStageRow({
|
||||
<Input
|
||||
value={stage.name}
|
||||
onChange={(e) => onNameChange(e.target.value)}
|
||||
className="h-7 flex-1 border-transparent bg-transparent text-sm text-white focus:border-slate-600"
|
||||
className="h-7 flex-1 border-transparent bg-transparent text-sm text-foreground focus:border-border"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={onRemove}
|
||||
className="text-slate-400 hover:text-red-400"
|
||||
className="text-muted-foreground hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
@ -431,14 +433,14 @@ function ColorSwatch({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="h-4 w-4 rounded-full border border-slate-600"
|
||||
className="h-4 w-4 rounded-full border border-border"
|
||||
style={{ backgroundColor: value }}
|
||||
aria-label="Change color"
|
||||
/>
|
||||
{open && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setOpen(false)} />
|
||||
<div className="absolute left-0 top-6 z-20 flex flex-wrap gap-1 rounded-lg border border-slate-700 bg-slate-900 p-2 shadow-lg w-36">
|
||||
<div className="absolute left-0 top-6 z-20 flex flex-wrap gap-1 rounded-lg border border-border bg-popover p-2 shadow-lg w-36">
|
||||
{colors.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
@ -450,7 +452,8 @@ function ColorSwatch({
|
||||
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: c,
|
||||
borderColor: c === value ? "white" : "transparent",
|
||||
borderColor:
|
||||
c === value ? "var(--foreground)" : "transparent",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
40
src/components/presence/presence-dot.tsx
Normal file
40
src/components/presence/presence-dot.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
105
src/components/presence/presence-heartbeat.tsx
Normal file
105
src/components/presence/presence-heartbeat.tsx
Normal 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;
|
||||
}
|
||||
481
src/components/settings/api-keys-settings.tsx
Normal file
481
src/components/settings/api-keys-settings.tsx
Normal 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 <key></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>
|
||||
);
|
||||
}
|
||||
@ -1,35 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { Check } from "lucide-react";
|
||||
import { Check, Moon, Palette, SunMoon, Sun } from "lucide-react";
|
||||
|
||||
import { useTheme } from "@/hooks/use-theme";
|
||||
import { THEMES, type ThemeId } from "@/lib/themes";
|
||||
import { MODES, THEMES, type Mode, type ThemeId } from "@/lib/themes";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SettingsPanelHead } from "./settings-panel-head";
|
||||
|
||||
/**
|
||||
* Appearance panel — color-theme picker.
|
||||
* Appearance panel — light/dark mode + accent-color picker.
|
||||
*
|
||||
* Click a card → applies + persists immediately. No save button:
|
||||
* the whole change is a single CSS-variable swap on <html>, there's
|
||||
* nothing to roll back. The active card carries a check chip + a
|
||||
* primary-tinted border so the current pick is obvious.
|
||||
* Two independent controls: a mode toggle (light / dark) and the
|
||||
* accent grid. Either applies + persists immediately. No save button:
|
||||
* each change is a single attribute swap on <html>, there's nothing
|
||||
* to roll back.
|
||||
*
|
||||
* Persistence: localStorage only (device-scoped). The boot script in
|
||||
* layout.tsx replays the choice before first paint on subsequent
|
||||
* layout.tsx replays both choices before first paint on subsequent
|
||||
* loads.
|
||||
*/
|
||||
export function AppearancePanel() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { theme, setTheme, mode, setMode } = useTheme();
|
||||
return (
|
||||
<section className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Color theme</h2>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Pick the accent color used across the app. All themes stay
|
||||
dark — only the primary color (buttons, active nav, badges)
|
||||
changes. Saved to this device.
|
||||
</p>
|
||||
<section className="max-w-3xl animate-in fade-in-50 duration-200">
|
||||
<SettingsPanelHead
|
||||
title="Appearance"
|
||||
description="Set the mode and accent colour used across the app. Saved to this device — try it, it changes live."
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<SunMoon className="size-4 text-muted-foreground" />
|
||||
Mode
|
||||
</h3>
|
||||
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Color mode"
|
||||
className="grid max-w-md grid-cols-2 gap-3"
|
||||
>
|
||||
{MODES.map((m) => (
|
||||
<ModeCard
|
||||
key={m}
|
||||
mode={m}
|
||||
isActive={m === mode}
|
||||
onPick={() => setMode(m)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground">
|
||||
<Palette className="size-4 text-muted-foreground" />
|
||||
Accent color
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{THEMES.map((t) => (
|
||||
@ -44,10 +69,55 @@ export function AppearancePanel() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
mode,
|
||||
isActive,
|
||||
onPick,
|
||||
}: {
|
||||
mode: Mode;
|
||||
isActive: boolean;
|
||||
onPick: () => void;
|
||||
}) {
|
||||
const isLight = mode === "light";
|
||||
const Icon = isLight ? Sun : Moon;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
onClick={onPick}
|
||||
aria-checked={isActive}
|
||||
aria-label={`Use ${mode} mode`}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
|
||||
isActive
|
||||
? "border-primary/60 ring-2 ring-primary/40"
|
||||
: "border-border hover:border-border hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-muted text-foreground"
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex-1 text-sm font-semibold capitalize text-foreground">
|
||||
{mode}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-[11px] font-medium text-primary">
|
||||
<Check className="h-3 w-3" />
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemeCard({
|
||||
id,
|
||||
name,
|
||||
@ -73,7 +143,7 @@ function ThemeCard({
|
||||
"flex flex-col gap-3 rounded-lg border bg-card p-4 text-left transition-colors",
|
||||
isActive
|
||||
? "border-primary/60 ring-2 ring-primary/40"
|
||||
: "border-slate-800 hover:border-slate-700 hover:bg-slate-800/40",
|
||||
: "border-border hover:border-border hover:bg-muted/40",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
@ -93,8 +163,8 @@ function ThemeCard({
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">{name}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-slate-400">
|
||||
<div className="text-sm font-semibold text-foreground">{name}</div>
|
||||
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
{tagline}
|
||||
</div>
|
||||
</div>
|
||||
@ -103,9 +173,9 @@ function ThemeCard({
|
||||
aria-hidden
|
||||
>
|
||||
<span className="flex-1" style={{ background: swatch }} />
|
||||
<span className="w-3 bg-slate-700" />
|
||||
<span className="w-3 bg-slate-800" />
|
||||
<span className="w-3 bg-slate-900" />
|
||||
<span className="w-3 bg-muted-foreground/60" />
|
||||
<span className="w-3 bg-muted" />
|
||||
<span className="w-3 bg-card" />
|
||||
</div>
|
||||
<span className="sr-only">Theme id: {id}</span>
|
||||
</button>
|
||||
|
||||
@ -1,29 +1,43 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Shield, SlidersHorizontal } from 'lucide-react';
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { CustomFieldsPanel } from '@/components/contacts/custom-fields-manager';
|
||||
import { SettingsChip } from './settings-chip';
|
||||
|
||||
/**
|
||||
* Settings → Custom Fields. Manage the account-wide custom contact field
|
||||
* catalogue (the same panel the Contacts page exposes via a dialog). Writes
|
||||
* are admin-gated by the caller and enforced by `custom_fields` RLS.
|
||||
* Settings → Custom Fields card. Manages the account-wide custom
|
||||
* contact field catalogue (the same panel the Contacts page exposes
|
||||
* via a dialog). Writes are admin-gated by the caller and enforced by
|
||||
* `custom_fields` RLS.
|
||||
*/
|
||||
export function CustomFieldsSettings() {
|
||||
return (
|
||||
<div className="mt-4 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Custom fields</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
Define extra contact fields (e.g. ZIP code, lead source). They appear
|
||||
on every contact and in the “Update Contact Field” automation action.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="border-slate-700 bg-slate-900 ring-0 ring-transparent">
|
||||
<CardContent className="pt-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-foreground">
|
||||
<SlidersHorizontal className="size-4 text-primary" />
|
||||
Custom fields
|
||||
<SettingsChip variant="admin" className="font-medium">
|
||||
<Shield />
|
||||
Admin
|
||||
</SettingsChip>
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
Extra contact fields (e.g. ZIP code, lead source). They appear on
|
||||
every contact and in the “Update Contact Field” automation action.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CustomFieldsPanel />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import {
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "@/components/ui/card";
|
||||
import { SettingsPanelHead } from "./settings-panel-head";
|
||||
|
||||
/**
|
||||
* Deals settings — account-wide default currency.
|
||||
@ -67,14 +68,18 @@ export function DealsSettings() {
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="mt-4 space-y-4">
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<section className="max-w-2xl animate-in fade-in-50 duration-200">
|
||||
<SettingsPanelHead
|
||||
title="Deals & currency"
|
||||
description="The currency used for new deals and for pipeline and dashboard totals."
|
||||
/>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-white">
|
||||
<CardTitle className="flex items-center gap-2 text-foreground">
|
||||
<Coins className="size-4 text-primary" />
|
||||
Default currency
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
New deals default to this currency, and pipeline and
|
||||
dashboard totals are shown in it. Existing deals keep the
|
||||
currency they were saved with.
|
||||
@ -82,12 +87,12 @@ export function DealsSettings() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-2 sm:max-w-xs">
|
||||
<Label className="text-slate-300">Currency</Label>
|
||||
<Label className="text-muted-foreground">Currency</Label>
|
||||
<select
|
||||
value={selected}
|
||||
onChange={(e) => setSelected(e.target.value)}
|
||||
disabled={!canEditSettings || profileLoading}
|
||||
className="h-9 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-primary focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
className="h-9 w-full rounded-lg border border-border bg-muted px-2.5 text-sm text-foreground outline-none focus:border-primary focus:ring-1 focus:ring-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{CURRENCIES.map((c) => (
|
||||
<option key={c.code} value={c.code}>
|
||||
@ -96,7 +101,7 @@ export function DealsSettings() {
|
||||
))}
|
||||
</select>
|
||||
{!canEditSettings && (
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only account admins can change the default currency.
|
||||
</p>
|
||||
)}
|
||||
|
||||
29
src/components/settings/fields-and-tags-panel.tsx
Normal file
29
src/components/settings/fields-and-tags-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -185,20 +185,20 @@ export function InviteMemberDialog({
|
||||
onOpenChange(next);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-md">
|
||||
<DialogContent className="bg-popover border-border sm:max-w-md">
|
||||
{result ? (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
|
||||
<Sparkles className="size-4 text-primary" />
|
||||
Invite created
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Share this link with your new teammate. They'll be able
|
||||
to sign up (or sign in) and join the account as{' '}
|
||||
<span className="font-medium text-slate-300">{result.role}</span>
|
||||
<span className="font-medium text-muted-foreground">{result.role}</span>
|
||||
. The link is valid for{' '}
|
||||
<span className="font-medium text-slate-300">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{result.expiresInDays} day{result.expiresInDays === 1 ? '' : 's'}
|
||||
</span>
|
||||
.
|
||||
@ -206,12 +206,12 @@ export function InviteMemberDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 py-2">
|
||||
<Label className="text-slate-300">Invite link</Label>
|
||||
<Label className="text-muted-foreground">Invite link</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={result.url}
|
||||
className="bg-slate-800 border-slate-700 text-white font-mono text-xs"
|
||||
className="bg-muted border-border text-foreground font-mono text-xs"
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<Button
|
||||
@ -250,7 +250,7 @@ export function InviteMemberDialog({
|
||||
className={buttonVariants({
|
||||
variant: 'outline',
|
||||
className:
|
||||
'w-full border-slate-700 text-slate-300 hover:bg-slate-800',
|
||||
'w-full border-border text-muted-foreground hover:bg-muted',
|
||||
})}
|
||||
>
|
||||
<MessageCircle className="size-4" />
|
||||
@ -258,7 +258,7 @@ export function InviteMemberDialog({
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<DialogFooter className="bg-popover border-border">
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
@ -270,8 +270,8 @@ export function InviteMemberDialog({
|
||||
) : (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Invite a teammate</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogTitle className="text-popover-foreground">Invite a teammate</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Generate a one-time invite link. Share it via WhatsApp,
|
||||
Slack, or any channel you like — no email service required.
|
||||
</DialogDescription>
|
||||
@ -279,12 +279,12 @@ export function InviteMemberDialog({
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Role</Label>
|
||||
<Label className="text-muted-foreground">Role</Label>
|
||||
<Select
|
||||
value={role}
|
||||
onValueChange={(v) => v && setRole(v as InviteRole)}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
|
||||
<SelectTrigger className="w-full bg-muted border-border text-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -293,18 +293,18 @@ export function InviteMemberDialog({
|
||||
<SelectItem value="viewer">Viewer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{ROLE_DESCRIPTIONS[role]}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Link valid for</Label>
|
||||
<Label className="text-muted-foreground">Link valid for</Label>
|
||||
<Select
|
||||
value={expiry}
|
||||
onValueChange={(v) => v && setExpiry(v)}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
|
||||
<SelectTrigger className="w-full bg-muted border-border text-foreground">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -318,29 +318,29 @@ export function InviteMemberDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">
|
||||
<Label className="text-muted-foreground">
|
||||
Label{' '}
|
||||
<span className="text-xs text-slate-500">(optional)</span>
|
||||
<span className="text-xs text-muted-foreground">(optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="e.g. Sara — support team"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
maxLength={MAX_LABEL_LEN}
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
className="bg-muted border-border text-foreground placeholder:text-muted-foreground"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Helps you remember who you sent the link to in the pending
|
||||
list below.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<DialogFooter className="bg-popover border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@ -25,20 +25,26 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Crown,
|
||||
Loader2,
|
||||
Mail,
|
||||
MailX,
|
||||
Plus,
|
||||
Shield,
|
||||
Trash2,
|
||||
UserCog,
|
||||
UserIcon,
|
||||
UsersRound,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import {
|
||||
Avatar,
|
||||
AvatarBadge,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
@ -58,8 +64,16 @@ import {
|
||||
} from '@/components/ui/select';
|
||||
import { RequireRole } from '@/components/auth/require-role';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { usePresence } from '@/hooks/use-presence';
|
||||
import type { AccountRole } from '@/lib/auth/roles';
|
||||
import { presenceLabel, summarize } from '@/lib/presence';
|
||||
import {
|
||||
PRESENCE_DOT_CLASS,
|
||||
PresenceDot,
|
||||
} from '@/components/presence/presence-dot';
|
||||
import { InviteMemberDialog } from './invite-member-dialog';
|
||||
import { SettingsPanelHead } from './settings-panel-head';
|
||||
import { ROLE_META } from './role-meta';
|
||||
|
||||
interface Member {
|
||||
user_id: string;
|
||||
@ -86,38 +100,10 @@ const EDITABLE_ROLES: { value: AccountRole; label: string; hint: string }[] = [
|
||||
{ value: 'viewer', label: 'Viewer', hint: 'Read-only across the app' },
|
||||
];
|
||||
|
||||
// Per-role chip metadata. The colour scale runs amber (owner —
|
||||
// scarce, immutable) → primary (admin — significant) → slate
|
||||
// (agent — operational default) → muted slate (viewer — read-
|
||||
// only). Mirrors the sidebar's ROLE_CHIP so the two surfaces
|
||||
// don't drift; once the surface stabilises this should hoist
|
||||
// into a shared module.
|
||||
const ROLE_CHIP: Record<
|
||||
AccountRole,
|
||||
{ icon: typeof Crown; label: string; className: string }
|
||||
> = {
|
||||
owner: {
|
||||
icon: Crown,
|
||||
label: 'Owner',
|
||||
className:
|
||||
'border-amber-500/40 bg-amber-500/10 text-amber-300',
|
||||
},
|
||||
admin: {
|
||||
icon: Shield,
|
||||
label: 'Admin',
|
||||
className: 'border-primary/40 bg-primary/10 text-primary',
|
||||
},
|
||||
agent: {
|
||||
icon: UserCog,
|
||||
label: 'Agent',
|
||||
className: 'border-slate-700 bg-slate-800 text-slate-300',
|
||||
},
|
||||
viewer: {
|
||||
icon: UserIcon,
|
||||
label: 'Viewer',
|
||||
className: 'border-slate-800 bg-slate-900 text-slate-500',
|
||||
},
|
||||
};
|
||||
// Per-role chip metadata (icon / label / colour) lives in the shared
|
||||
// ROLE_META module so this roster and the Overview identity chip can't
|
||||
// drift. The colour scale runs amber (owner — scarce, immutable) →
|
||||
// primary (admin) → muted (agent / viewer).
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
// Match the rest of the dashboard's locale-light formatting.
|
||||
@ -140,6 +126,7 @@ function fmtExpiresIn(iso: string): string {
|
||||
|
||||
export function MembersTab() {
|
||||
const { user, canManageMembers } = useAuth();
|
||||
const { getPresence, getRow, now } = usePresence();
|
||||
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||
@ -292,37 +279,63 @@ export function MembersTab() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 mt-4">
|
||||
{/* Header + invite button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Account members</h2>
|
||||
<p className="text-sm text-slate-400">
|
||||
People with access to this account. Roles control what each
|
||||
teammate can do.
|
||||
</p>
|
||||
</div>
|
||||
<section className="animate-in fade-in-50 space-y-6 duration-200">
|
||||
<SettingsPanelHead
|
||||
title="Team members"
|
||||
description="People with access to this account. Roles control what each teammate can do."
|
||||
action={
|
||||
<RequireRole min="admin">
|
||||
<Button
|
||||
onClick={() => setInviteOpen(true)}
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
<Button onClick={() => setInviteOpen(true)}>
|
||||
<Plus className="size-4" />
|
||||
Invite member
|
||||
</Button>
|
||||
</RequireRole>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Live presence summary across the roster. Updates without a
|
||||
full refresh as heartbeats and the local re-derive tick land. */}
|
||||
{members.length > 0 &&
|
||||
(() => {
|
||||
const counts = summarize(members.map((m) => getPresence(m.user_id)));
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<PresenceDot status="online" />
|
||||
{counts.online} online
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<PresenceDot status="away" />
|
||||
{counts.away} away
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<PresenceDot status="offline" />
|
||||
{counts.offline} offline
|
||||
</span>
|
||||
<span className="text-muted-foreground/70">
|
||||
· {members.length} member{members.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Roster */}
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-slate-800">
|
||||
<ul className="divide-y divide-border">
|
||||
{members.map((member) => {
|
||||
const roleMeta = ROLE_CHIP[member.role];
|
||||
const roleMeta = ROLE_META[member.role];
|
||||
const RoleIcon = roleMeta.icon;
|
||||
const isSelf = member.user_id === user?.id;
|
||||
const isOwnerRow = member.role === 'owner';
|
||||
const isBusy = pendingMemberAction === member.user_id;
|
||||
const presence = getPresence(member.user_id);
|
||||
const presenceRow = getRow(member.user_id);
|
||||
const presenceText = presenceLabel(
|
||||
presence,
|
||||
presenceRow?.last_seen_at ?? null,
|
||||
now,
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
@ -335,6 +348,9 @@ export function MembersTab() {
|
||||
className="flex flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:gap-4"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-4">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Avatar className="size-9 shrink-0">
|
||||
{member.avatar_url ? (
|
||||
<AvatarImage
|
||||
@ -347,21 +363,34 @@ export function MembersTab() {
|
||||
.charAt(0)
|
||||
.toUpperCase()}
|
||||
</AvatarFallback>
|
||||
{/* role+label so screen readers announce
|
||||
presence — the hover tooltip alone isn't
|
||||
reachable by keyboard/AT on a non-focusable
|
||||
avatar. */}
|
||||
<AvatarBadge
|
||||
role="img"
|
||||
aria-label={presenceText}
|
||||
className={PRESENCE_DOT_CLASS[presence]}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{presenceText}</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium text-white">
|
||||
<span className="truncate text-sm font-medium text-foreground">
|
||||
{member.full_name || 'Unnamed'}
|
||||
</span>
|
||||
{isSelf && (
|
||||
<Badge className="bg-slate-800 text-slate-300 border-slate-700 text-[10px] uppercase tracking-wide">
|
||||
<Badge className="bg-muted text-muted-foreground border-border text-[10px] uppercase tracking-wide">
|
||||
You
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{member.email && (
|
||||
<p className="truncate text-xs text-slate-500">
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{member.email}
|
||||
</p>
|
||||
)}
|
||||
@ -370,7 +399,7 @@ export function MembersTab() {
|
||||
|
||||
{/* Joined date stays desktop-only. The mobile row's
|
||||
vertical density makes the joined date noise. */}
|
||||
<div className="hidden sm:block text-right text-xs text-slate-500">
|
||||
<div className="hidden sm:block text-right text-xs text-muted-foreground">
|
||||
Joined {fmtDate(member.joined_at)}
|
||||
</div>
|
||||
|
||||
@ -394,7 +423,7 @@ export function MembersTab() {
|
||||
}
|
||||
>
|
||||
<SelectTrigger
|
||||
className="w-32 bg-slate-800 border-slate-700 text-slate-200"
|
||||
className="w-32 bg-muted border-border text-foreground"
|
||||
disabled={isBusy}
|
||||
>
|
||||
<SelectValue />
|
||||
@ -446,11 +475,11 @@ export function MembersTab() {
|
||||
<RequireRole min="admin">
|
||||
<div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<UsersRound className="size-4 text-slate-400" />
|
||||
<h3 className="text-sm font-semibold text-white">
|
||||
<UsersRound className="size-4 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Pending invitations
|
||||
</h3>
|
||||
<Badge className="bg-slate-800 text-slate-400 border-slate-700">
|
||||
<Badge className="bg-muted text-muted-foreground border-border">
|
||||
{invitations.length}
|
||||
</Badge>
|
||||
</div>
|
||||
@ -460,7 +489,7 @@ export function MembersTab() {
|
||||
front (rather than letting the user discover it by
|
||||
looking for a button) keeps it from feeling like a bug. */}
|
||||
{invitations.length > 0 ? (
|
||||
<p className="mb-3 text-xs text-slate-500">
|
||||
<p className="mb-3 text-xs text-muted-foreground">
|
||||
The plaintext invite URL is only shown once at creation
|
||||
for security — to re-share, revoke the invite below and
|
||||
create a new one.
|
||||
@ -468,24 +497,24 @@ export function MembersTab() {
|
||||
) : null}
|
||||
|
||||
{invitations.length === 0 ? (
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Mail className="size-6 text-slate-600" />
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
<Mail className="size-6 text-muted-foreground" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
No pending invitations.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-500">
|
||||
Click <span className="text-slate-300">Invite member</span>{' '}
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Click <span className="text-muted-foreground">Invite member</span>{' '}
|
||||
above to generate a shareable link.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<ul className="divide-y divide-slate-800">
|
||||
<ul className="divide-y divide-border">
|
||||
{invitations.map((inv) => {
|
||||
const inviteRoleMeta = ROLE_CHIP[inv.role];
|
||||
const inviteRoleMeta = ROLE_META[inv.role];
|
||||
const InviteRoleIcon = inviteRoleMeta.icon;
|
||||
return (
|
||||
<li
|
||||
@ -494,7 +523,7 @@ export function MembersTab() {
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-white">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{inv.label || 'Untitled invite'}
|
||||
</span>
|
||||
<span
|
||||
@ -504,7 +533,7 @@ export function MembersTab() {
|
||||
{inviteRoleMeta.label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-slate-500">
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Created {fmtDate(inv.created_at)} · {fmtExpiresIn(inv.expires_at)}
|
||||
</p>
|
||||
</div>
|
||||
@ -544,15 +573,15 @@ export function MembersTab() {
|
||||
if (!open) setRemovingMember(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-sm">
|
||||
<DialogContent className="bg-popover border-border sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-white">
|
||||
<DialogTitle className="flex items-center gap-2 text-popover-foreground">
|
||||
<AlertTriangle className="size-4 text-amber-400" />
|
||||
Remove member
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
Remove{' '}
|
||||
<span className="font-medium text-slate-300">
|
||||
<span className="font-medium text-muted-foreground">
|
||||
{removingMember?.full_name || 'this teammate'}
|
||||
</span>{' '}
|
||||
from the account? They'll be signed out of this account
|
||||
@ -560,11 +589,11 @@ export function MembersTab() {
|
||||
login isn't deleted.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<DialogFooter className="bg-popover border-border">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setRemovingMember(null)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
className="border-border text-muted-foreground hover:bg-muted"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
@ -585,6 +614,6 @@ export function MembersTab() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -81,13 +81,13 @@ export function PasswordForm() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-900/40 border-slate-800">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-white">
|
||||
<CardTitle className="flex items-center gap-2 text-foreground">
|
||||
<KeyRound className="size-4 text-primary" />
|
||||
Password
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
Use at least {MIN_PASSWORD} characters. You will stay signed in on
|
||||
this device after changing it.
|
||||
</CardDescription>
|
||||
@ -96,7 +96,7 @@ export function PasswordForm() {
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="current-password" className="text-slate-200">
|
||||
<Label htmlFor="current-password" className="text-foreground">
|
||||
Current password
|
||||
</Label>
|
||||
<Input
|
||||
@ -112,7 +112,7 @@ export function PasswordForm() {
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password" className="text-slate-200">
|
||||
<Label htmlFor="new-password" className="text-foreground">
|
||||
New password
|
||||
</Label>
|
||||
<Input
|
||||
@ -127,7 +127,7 @@ export function PasswordForm() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" className="text-slate-200">
|
||||
<Label htmlFor="confirm-password" className="text-foreground">
|
||||
Confirm new password
|
||||
</Label>
|
||||
<Input
|
||||
@ -144,7 +144,7 @@ export function PasswordForm() {
|
||||
</div>
|
||||
|
||||
{confirmError && (
|
||||
<p className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-300">
|
||||
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{confirmError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -14,13 +14,8 @@ import {
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from '@/components/ui/avatar';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { SettingsPanelHead } from './settings-panel-head';
|
||||
|
||||
const MAX_AVATAR_BYTES = 2 * 1024 * 1024;
|
||||
const ALLOWED_MIME = new Set([
|
||||
@ -210,17 +205,14 @@ export function ProfileForm() {
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-900/40 border-slate-800">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Profile</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
How you show up across the app. Your avatar and name appear in the
|
||||
header, sidebar, and anywhere your teammates see you.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<section className="max-w-2xl animate-in fade-in-50 duration-200">
|
||||
<SettingsPanelHead
|
||||
title="Your profile"
|
||||
description="How you show up across the app. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you."
|
||||
/>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<Card>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Avatar row */}
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<Avatar size="lg" className="size-16">
|
||||
@ -255,13 +247,13 @@ export function ProfileForm() {
|
||||
variant="ghost"
|
||||
onClick={onRemoveAvatar}
|
||||
disabled={saving}
|
||||
className="text-slate-400 hover:text-white"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
<p className="w-full text-xs text-slate-500">
|
||||
<p className="w-full text-xs text-muted-foreground">
|
||||
PNG, JPG, WebP, or GIF. Up to 2 MB.
|
||||
</p>
|
||||
</div>
|
||||
@ -269,7 +261,7 @@ export function ProfileForm() {
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-full-name" className="text-slate-200">
|
||||
<Label htmlFor="profile-full-name" className="text-foreground">
|
||||
Display name
|
||||
</Label>
|
||||
<Input
|
||||
@ -285,7 +277,7 @@ export function ProfileForm() {
|
||||
|
||||
{/* Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-email" className="text-slate-200">
|
||||
<Label htmlFor="profile-email" className="text-foreground">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
@ -297,7 +289,7 @@ export function ProfileForm() {
|
||||
required
|
||||
/>
|
||||
{emailChangePending && (
|
||||
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-300">
|
||||
<p className="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
|
||||
<Mail className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
Check the inbox for <strong>{profile?.email}</strong> and{' '}
|
||||
@ -309,24 +301,24 @@ export function ProfileForm() {
|
||||
</div>
|
||||
|
||||
{/* Read-only block */}
|
||||
<div className="rounded-lg border border-slate-800 bg-slate-900/60 p-4">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
<div className="rounded-lg border border-border bg-muted p-4">
|
||||
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Account details
|
||||
</p>
|
||||
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-slate-500">Role</dt>
|
||||
<dd className="mt-0.5 font-mono text-slate-200">
|
||||
<dt className="text-muted-foreground">Role</dt>
|
||||
<dd className="mt-0.5 font-mono text-foreground">
|
||||
{profile?.role ?? 'user'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-slate-500">Joined</dt>
|
||||
<dd className="mt-0.5 text-slate-200">{joined}</dd>
|
||||
<dt className="text-muted-foreground">Joined</dt>
|
||||
<dd className="mt-0.5 text-foreground">{joined}</dd>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<dt className="text-slate-500">User ID</dt>
|
||||
<dd className="mt-0.5 break-all font-mono text-xs text-slate-400">
|
||||
<dt className="text-muted-foreground">User ID</dt>
|
||||
<dd className="mt-0.5 break-all font-mono text-xs text-muted-foreground">
|
||||
{user?.id ?? '—'}
|
||||
</dd>
|
||||
</div>
|
||||
@ -334,12 +326,15 @@ export function ProfileForm() {
|
||||
</div>
|
||||
|
||||
{!profile && (
|
||||
<p className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CircleAlert className="size-4" />
|
||||
Loading your profile…
|
||||
</p>
|
||||
)}
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={saving || !dirty || !profile}>
|
||||
{saving ? (
|
||||
@ -353,7 +348,6 @@ export function ProfileForm() {
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
51
src/components/settings/role-meta.ts
Normal file
51
src/components/settings/role-meta.ts
Normal 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',
|
||||
},
|
||||
};
|
||||
24
src/components/settings/security-panel.tsx
Normal file
24
src/components/settings/security-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -49,13 +49,13 @@ export function SessionsCard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-slate-900/40 border-slate-800">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-white">
|
||||
<CardTitle className="flex items-center gap-2 text-foreground">
|
||||
<LogOut className="size-4 text-primary" />
|
||||
Active sessions
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
<CardDescription className="text-muted-foreground">
|
||||
Sign out of every device where you're logged in — including
|
||||
this one. Useful if you lost a laptop or shared your password.
|
||||
</CardDescription>
|
||||
|
||||
64
src/components/settings/settings-chip.tsx
Normal file
64
src/components/settings/settings-chip.tsx
Normal 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,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
289
src/components/settings/settings-overview.tsx
Normal file
289
src/components/settings/settings-overview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
src/components/settings/settings-panel-head.tsx
Normal file
41
src/components/settings/settings-panel-head.tsx
Normal 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
Loading…
Reference in New Issue
Block a user