mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Initial scaffold: WhatsApp CRM with Next.js + Supabase
Complete WhatsApp CRM application built with Next.js 14 (App Router), Supabase (PostgreSQL + Auth + Realtime), and shadcn/ui. Features: - Auth: Login, signup, forgot password with Supabase Auth - Dashboard: Analytics overview with stats, recent activity - Inbox: Real-time WhatsApp conversations with message threading - Contacts: CRUD with tags, custom fields, notes, CSV import - Broadcasts: 4-step wizard with audience selection, template sending - Pipelines: Kanban board with deal management - Settings: WhatsApp API config, templates, tags - WhatsApp API: Webhook (verify + receive), send, broadcast, media proxy - Database: 16 tables with RLS policies, indexes, realtime subscriptions - Dark theme throughout with emerald accents Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
de3deb32da
commit
658d070811
167
package-lock.json
generated
167
package-lock.json
generated
@ -9,13 +9,17 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@supabase/ssr": "^0.10.2",
|
||||
"@supabase/supabase-js": "^2.103.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "16.2.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
@ -1956,6 +1960,117 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.103.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.3.tgz",
|
||||
"integrity": "sha512-SMDJ4vg5jLXNEHdhN4J4ujSb203WangbDw1n3VaARH0ZqM51E6lJnoUAHlpQU9N7SzP0hfgghA9IvT8c7tGRfg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.103.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.3.tgz",
|
||||
"integrity": "sha512-A2ZHi95GIRRlN9LGOSa/zGEIPg9taR1giDI9Gkfkgrcz0YmKV8ShiAplIrKsHQFdkzKxtsO3maJF0efL+i31mg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/phoenix": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz",
|
||||
"integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.103.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.3.tgz",
|
||||
"integrity": "sha512-S0k/9FJVXDeejNfQLCJwRlm4IH8Wet/HEEdBTBpX6/G2o1eU/6CjQop/hJPZIwlQkI6D/zbHH8KymuCsBgy6jA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.103.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.3.tgz",
|
||||
"integrity": "sha512-fUvKtSXMUk1BkApVwAurWtHF4Vzbb0UB9aC/fQXrRBek7Ta3Kaora+wHf/fGwFNQs7uRz+mvjIVpzLfpR32VXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/phoenix": "^0.4.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"tslib": "2.8.1",
|
||||
"ws": "^8.18.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/ssr": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.10.2.tgz",
|
||||
"integrity": "sha512-JFbchN63CXLFHJRNT7udec4/RoD9PmXkSGko3QSO6vUuqGBtSzdmxR7FPfQNr7SuFd65I7Xv46q66ALjEN1cgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@supabase/supabase-js": "^2.102.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/ssr/node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.103.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.3.tgz",
|
||||
"integrity": "sha512-5bAIEubrw5keHcdKR2RTois0O1M2Ilx4UYuzOzc07G6mLGCPS/8t1nbC6Vq451pnxR3sK+rmtFHWb9CY/OPjAw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.103.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.3.tgz",
|
||||
"integrity": "sha512-DuPiAz5pIJsTAQCt7B6bDZrnLzlq9+/5bta/GWTsgpLn6AkuZQcmYsQHYplv4skQ8U2raKY5HASQOu4KtYq9Qw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.103.3",
|
||||
"@supabase/functions-js": "2.103.3",
|
||||
"@supabase/postgrest-js": "2.103.3",
|
||||
"@supabase/realtime-js": "2.103.3",
|
||||
"@supabase/storage-js": "2.103.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.15",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||
@ -2347,7 +2462,6 @@
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
@ -2385,6 +2499,15 @@
|
||||
"integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
|
||||
@ -3907,7 +4030,6 @@
|
||||
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/kossnocorp"
|
||||
@ -5593,6 +5715,15 @@
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||
@ -8555,6 +8686,16 @@
|
||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz",
|
||||
"integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
@ -9265,7 +9406,6 @@
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicorn-magic": {
|
||||
@ -9599,6 +9739,27 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
||||
|
||||
@ -10,13 +10,17 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.0",
|
||||
"@supabase/ssr": "^0.10.2",
|
||||
"@supabase/supabase-js": "^2.103.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^1.8.0",
|
||||
"next": "16.2.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"shadcn": "^4.2.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
|
||||
131
src/app/(auth)/forgot-password/page.tsx
Normal file
131
src/app/(auth)/forgot-password/page.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { MessageSquare, CheckCircle, ArrowLeft } from "lucide-react";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const supabase = createClient();
|
||||
|
||||
const handleReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
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">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<CheckCircle className="h-6 w-6 text-emerald-500" />
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">
|
||||
Check your email
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
We've sent a password reset link to{" "}
|
||||
<span className="text-white">{email}</span>. Please check your
|
||||
inbox.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
Back to sign in
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<MessageSquare className="h-6 w-6 text-emerald-500" />
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">Reset password</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Enter your email and we'll send you a reset link
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleReset} className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email" className="text-slate-300">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
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-emerald-500 focus-visible:ring-emerald-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 h-10 w-full bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Sending..." : "Send reset link"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<Link
|
||||
href="/login"
|
||||
className="mt-6 flex items-center justify-center gap-2 text-sm text-slate-400 hover:text-slate-300"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to sign in
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/app/(auth)/login/page.tsx
Normal file
126
src/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const supabase = createClient();
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
router.push("/dashboard");
|
||||
};
|
||||
|
||||
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">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<MessageSquare className="h-6 w-6 text-emerald-500" />
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">Welcome back</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Sign in to your WaCRM account
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email" className="text-slate-300">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
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-emerald-500 focus-visible:ring-emerald-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password" className="text-slate-300">
|
||||
Password
|
||||
</Label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-emerald-500 hover:text-emerald-400"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
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-emerald-500 focus-visible:ring-emerald-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 h-10 w-full bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-slate-400">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className="text-emerald-500 hover:text-emerald-400"
|
||||
>
|
||||
Create account
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
src/app/(auth)/signup/page.tsx
Normal file
198
src/app/(auth)/signup/page.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { MessageSquare, CheckCircle } from "lucide-react";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [fullName, setFullName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const supabase = createClient();
|
||||
|
||||
const handleSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError("Passwords do not match");
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError("Password must be at least 6 characters");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: {
|
||||
full_name: fullName,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (error) {
|
||||
setError(error.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
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">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<CheckCircle className="h-6 w-6 text-emerald-500" />
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">
|
||||
Check your email
|
||||
</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
We've sent a confirmation link to{" "}
|
||||
<span className="text-white">{email}</span>. Please check your
|
||||
inbox and click the link to verify your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link href="/login">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||
>
|
||||
Back to sign in
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
<CardHeader className="items-center text-center">
|
||||
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||
<MessageSquare className="h-6 w-6 text-emerald-500" />
|
||||
</div>
|
||||
<CardTitle className="text-xl text-white">Create account</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Get started with WaCRM
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSignup} className="flex flex-col gap-4">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="fullName" className="text-slate-300">
|
||||
Full name
|
||||
</Label>
|
||||
<Input
|
||||
id="fullName"
|
||||
type="text"
|
||||
placeholder="John Doe"
|
||||
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-emerald-500 focus-visible:ring-emerald-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="email" className="text-slate-300">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
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-emerald-500 focus-visible:ring-emerald-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="password" className="text-slate-300">
|
||||
Password
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="At least 6 characters"
|
||||
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-emerald-500 focus-visible:ring-emerald-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="confirmPassword" className="text-slate-300">
|
||||
Confirm password
|
||||
</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
placeholder="Repeat your password"
|
||||
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-emerald-500 focus-visible:ring-emerald-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 h-10 w-full bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "Creating account..." : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-slate-400">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="text-emerald-500 hover:text-emerald-400"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/app/(dashboard)/automations/page.tsx
Normal file
32
src/app/(dashboard)/automations/page.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Zap } from "lucide-react";
|
||||
|
||||
export default function AutomationsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Automations</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Automate your WhatsApp messaging workflows
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-700 bg-slate-900/50 py-20">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10">
|
||||
<Zap className="h-8 w-8 text-emerald-500" />
|
||||
</div>
|
||||
<h3 className="mt-6 text-lg font-semibold text-white">Coming Soon</h3>
|
||||
<p className="mt-2 max-w-sm text-center text-sm text-slate-400">
|
||||
Set up automated responses, workflows, and triggers for your WhatsApp
|
||||
messages.
|
||||
</p>
|
||||
<div className="mt-6 rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5">
|
||||
<p className="text-xs text-slate-400">
|
||||
This feature is under development
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
src/app/(dashboard)/broadcasts/[id]/page.tsx
Normal file
259
src/app/(dashboard)/broadcasts/[id]/page.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Broadcast, BroadcastRecipient } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Users,
|
||||
Send,
|
||||
CheckCheck,
|
||||
Eye,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const statusConfig: Record<string, { label: string; classes: string }> = {
|
||||
draft: { label: 'Draft', classes: 'bg-slate-500/10 text-slate-400 border-slate-500/20' },
|
||||
scheduled: { label: 'Scheduled', classes: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
|
||||
sending: { label: 'Sending', classes: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20' },
|
||||
sent: { label: 'Sent', classes: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' },
|
||||
failed: { label: 'Failed', classes: 'bg-red-500/10 text-red-400 border-red-500/20' },
|
||||
};
|
||||
|
||||
const recipientStatusConfig: Record<string, { label: string; classes: string }> = {
|
||||
pending: { label: 'Pending', classes: 'bg-slate-500/10 text-slate-400 border-slate-500/20' },
|
||||
sent: { label: 'Sent', classes: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
|
||||
delivered: { label: 'Delivered', classes: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' },
|
||||
read: { label: 'Read', classes: 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20' },
|
||||
replied: { label: 'Replied', classes: 'bg-purple-500/10 text-purple-400 border-purple-500/20' },
|
||||
failed: { label: 'Failed', classes: 'bg-red-500/10 text-red-400 border-red-500/20' },
|
||||
};
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: number;
|
||||
total: number;
|
||||
icon: React.ReactNode;
|
||||
color: string;
|
||||
}
|
||||
|
||||
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="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>
|
||||
</div>
|
||||
<p className="mt-3 text-2xl font-bold text-white">{value.toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-400">{label}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BroadcastDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const broadcastId = params.id as string;
|
||||
|
||||
const [broadcast, setBroadcast] = useState<Broadcast | null>(null);
|
||||
const [recipients, setRecipients] = useState<BroadcastRecipient[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
|
||||
const { data: bc, error: bcError } = await supabase
|
||||
.from('broadcasts')
|
||||
.select('*')
|
||||
.eq('id', broadcastId)
|
||||
.single();
|
||||
|
||||
if (bcError) throw bcError;
|
||||
setBroadcast(bc);
|
||||
|
||||
const { data: recs, error: recsError } = await supabase
|
||||
.from('broadcast_recipients')
|
||||
.select('*, contact:contacts(*)')
|
||||
.eq('broadcast_id', broadcastId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (recsError) throw recsError;
|
||||
setRecipients(recs ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load broadcast');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchData();
|
||||
}, [broadcastId]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !broadcast) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||
<p className="text-sm text-red-400">{error ?? 'Broadcast not found'}</p>
|
||||
<Button variant="outline" onClick={() => router.push('/broadcasts')}>
|
||||
Back to Broadcasts
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const status = statusConfig[broadcast.status] ?? statusConfig.draft;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => router.push('/broadcasts')}
|
||||
className="border-slate-700"
|
||||
>
|
||||
<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>
|
||||
<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">
|
||||
<span>Template: {broadcast.template_name}</span>
|
||||
<span>-</span>
|
||||
<span>Created {new Date(broadcast.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<StatCard
|
||||
label="Total Recipients"
|
||||
value={broadcast.total_recipients}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
color="bg-slate-800 text-slate-300"
|
||||
/>
|
||||
<StatCard
|
||||
label="Sent"
|
||||
value={broadcast.sent_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<Send className="h-4 w-4" />}
|
||||
color="bg-blue-500/10 text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Delivered"
|
||||
value={broadcast.delivered_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<CheckCheck className="h-4 w-4" />}
|
||||
color="bg-emerald-500/10 text-emerald-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Read"
|
||||
value={broadcast.read_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<Eye className="h-4 w-4" />}
|
||||
color="bg-emerald-500/10 text-emerald-300"
|
||||
/>
|
||||
<StatCard
|
||||
label="Failed"
|
||||
value={broadcast.failed_count}
|
||||
total={broadcast.total_recipients}
|
||||
icon={<AlertCircle className="h-4 w-4" />}
|
||||
color="bg-red-500/10 text-red-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recipients Table */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="border-b border-slate-800 px-4 py-3">
|
||||
<h2 className="text-sm font-medium text-white">
|
||||
Recipients ({recipients.length})
|
||||
</h2>
|
||||
</div>
|
||||
{recipients.length === 0 ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-400">No recipients found.</p>
|
||||
</div>
|
||||
) : (
|
||||
<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 At</TableHead>
|
||||
<TableHead className="text-slate-400">Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recipients.map((recipient) => {
|
||||
const rStatus =
|
||||
recipientStatusConfig[recipient.status] ?? recipientStatusConfig.pending;
|
||||
|
||||
return (
|
||||
<TableRow key={recipient.id} className="border-slate-800">
|
||||
<TableCell className="font-medium text-white">
|
||||
{recipient.contact?.name ?? 'Unknown'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300">
|
||||
{recipient.contact?.phone ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`}
|
||||
>
|
||||
{rStatus.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{recipient.sent_at
|
||||
? new Date(recipient.sent_at).toLocaleString()
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-xs text-red-400">
|
||||
{recipient.error_message ?? '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
src/app/(dashboard)/broadcasts/new/page.tsx
Normal file
156
src/app/(dashboard)/broadcasts/new/page.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { MessageTemplate } from '@/types';
|
||||
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
|
||||
import { Step2SelectAudience } from '@/components/broadcasts/step2-select-audience';
|
||||
import { Step3Personalize } from '@/components/broadcasts/step3-personalize';
|
||||
import { Step4ScheduleSend } from '@/components/broadcasts/step4-schedule-send';
|
||||
import { useBroadcastSending } from '@/hooks/use-broadcast-sending';
|
||||
import { Check } from 'lucide-react';
|
||||
|
||||
const steps = [
|
||||
{ label: 'Template', key: 'template' },
|
||||
{ label: 'Audience', key: 'audience' },
|
||||
{ label: 'Personalize', key: 'personalize' },
|
||||
{ label: 'Send', key: 'send' },
|
||||
] as const;
|
||||
|
||||
export default function NewBroadcastPage() {
|
||||
const router = useRouter();
|
||||
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
|
||||
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [template, setTemplate] = useState<MessageTemplate | null>(null);
|
||||
const [audience, setAudience] = useState<{
|
||||
type: 'all' | 'tags' | 'custom_field' | 'csv';
|
||||
tagIds?: string[];
|
||||
csvContacts?: { phone: string; name?: string }[];
|
||||
}>({ type: 'all' });
|
||||
const [variables, setVariables] = useState<
|
||||
Record<string, { type: 'static' | 'field'; value: string }>
|
||||
>({});
|
||||
const [name, setName] = useState('');
|
||||
|
||||
async function handleSend() {
|
||||
if (!template) return;
|
||||
|
||||
try {
|
||||
const broadcastId = await createAndSendBroadcast({
|
||||
name,
|
||||
template,
|
||||
audience: {
|
||||
type: audience.type === 'custom_field' ? 'all' : audience.type,
|
||||
tagIds: audience.tagIds,
|
||||
csvContacts: audience.csvContacts,
|
||||
},
|
||||
variables,
|
||||
});
|
||||
router.push(`/broadcasts/${broadcastId}`);
|
||||
} catch (err) {
|
||||
console.error('Broadcast failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
Create and send a broadcast message to your contacts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-between">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = index === currentStep;
|
||||
const isCompleted = index < currentStep;
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex flex-1 items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium transition-all ${
|
||||
isCompleted
|
||||
? 'bg-emerald-500 text-white'
|
||||
: isActive
|
||||
? 'border-2 border-emerald-500 bg-emerald-500/10 text-emerald-400'
|
||||
: 'border border-slate-700 bg-slate-800 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
|
||||
</div>
|
||||
<span
|
||||
className={`hidden text-sm font-medium sm:block ${
|
||||
isActive ? 'text-white' : isCompleted ? 'text-emerald-400' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`mx-3 h-px flex-1 ${
|
||||
index < currentStep ? 'bg-emerald-500' : 'bg-slate-800'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step Content */}
|
||||
<div className="relative min-h-[400px]">
|
||||
<div
|
||||
className="transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
opacity: isProcessing ? 0.6 : 1,
|
||||
pointerEvents: isProcessing ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
{currentStep === 0 && (
|
||||
<Step1ChooseTemplate
|
||||
selectedTemplate={template}
|
||||
onSelect={setTemplate}
|
||||
onNext={() => setCurrentStep(1)}
|
||||
onBack={() => router.push('/broadcasts')}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Step2SelectAudience
|
||||
audience={audience}
|
||||
onUpdate={setAudience}
|
||||
onNext={() => setCurrentStep(2)}
|
||||
onBack={() => setCurrentStep(0)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 2 && template && (
|
||||
<Step3Personalize
|
||||
template={template}
|
||||
variables={variables}
|
||||
onUpdate={setVariables}
|
||||
onNext={() => setCurrentStep(3)}
|
||||
onBack={() => setCurrentStep(1)}
|
||||
/>
|
||||
)}
|
||||
{currentStep === 3 && template && (
|
||||
<Step4ScheduleSend
|
||||
name={name}
|
||||
onNameChange={setName}
|
||||
template={template}
|
||||
audience={audience}
|
||||
onSend={handleSend}
|
||||
onBack={() => setCurrentStep(2)}
|
||||
isProcessing={isProcessing}
|
||||
progress={progress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
170
src/app/(dashboard)/broadcasts/page.tsx
Normal file
170
src/app/(dashboard)/broadcasts/page.tsx
Normal file
@ -0,0 +1,170 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Broadcast } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Radio, Plus, Loader2 } from 'lucide-react';
|
||||
|
||||
const statusConfig: Record<string, { label: string; classes: string }> = {
|
||||
draft: { label: 'Draft', classes: 'bg-slate-500/10 text-slate-400 border-slate-500/20' },
|
||||
scheduled: { label: 'Scheduled', classes: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
|
||||
sending: { label: 'Sending', classes: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20' },
|
||||
sent: { label: 'Sent', classes: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' },
|
||||
failed: { label: 'Failed', classes: 'bg-red-500/10 text-red-400 border-red-500/20' },
|
||||
};
|
||||
|
||||
export default function BroadcastsPage() {
|
||||
const router = useRouter();
|
||||
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchBroadcasts() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('broadcasts')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (fetchError) throw fetchError;
|
||||
setBroadcasts(data ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load broadcasts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchBroadcasts();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<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">
|
||||
Send bulk messages to your contacts using approved templates.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push('/broadcasts/new')}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Broadcast
|
||||
</Button>
|
||||
</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">
|
||||
Create your first broadcast to reach your contacts at scale.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => router.push('/broadcasts/new')}
|
||||
className="mt-4 bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Broadcast
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||
<TableHead className="text-slate-400">Name</TableHead>
|
||||
<TableHead className="text-slate-400">Template</TableHead>
|
||||
<TableHead className="text-slate-400">Status</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">Recipients</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">Sent</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">Delivered</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">Read</TableHead>
|
||||
<TableHead className="text-slate-400 text-right">Failed</TableHead>
|
||||
<TableHead className="text-slate-400">Created</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{broadcasts.map((broadcast) => {
|
||||
const status = statusConfig[broadcast.status] ?? statusConfig.draft;
|
||||
return (
|
||||
<TableRow
|
||||
key={broadcast.id}
|
||||
className="cursor-pointer border-slate-800 hover:bg-slate-800/50"
|
||||
onClick={() => router.push(`/broadcasts/${broadcast.id}`)}
|
||||
>
|
||||
<TableCell className="font-medium text-white">
|
||||
{broadcast.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300">
|
||||
{broadcast.template_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.total_recipients}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.sent_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.delivered_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.read_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-slate-300">
|
||||
{broadcast.failed_count}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-400">
|
||||
{new Date(broadcast.created_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
487
src/app/(dashboard)/contacts/page.tsx
Normal file
487
src/app/(dashboard)/contacts/page.tsx
Normal file
@ -0,0 +1,487 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import type { Contact, Tag, ContactTag } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Upload,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Users,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from 'lucide-react';
|
||||
import { ContactForm } from '@/components/contacts/contact-form';
|
||||
import { ContactDetailView } from '@/components/contacts/contact-detail-view';
|
||||
import { ImportModal } from '@/components/contacts/import-modal';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
interface ContactWithTags extends Contact {
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
||||
export default function ContactsPage() {
|
||||
const supabase = createClient();
|
||||
|
||||
const [contacts, setContacts] = useState<ContactWithTags[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
// Modals
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editContact, setEditContact] = useState<Contact | null>(null);
|
||||
const [editContactTags, setEditContactTags] = useState<ContactTag[]>([]);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [detailContactId, setDetailContactId] = useState<string | null>(null);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// All tags for display
|
||||
const [tagsMap, setTagsMap] = useState<Record<string, Tag>>({});
|
||||
|
||||
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);
|
||||
}
|
||||
}, [supabase]);
|
||||
|
||||
const fetchContacts = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
const from = page * PAGE_SIZE;
|
||||
const to = from + PAGE_SIZE - 1;
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
const { data, count, error } = await query;
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to load contacts');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setTotalCount(count ?? 0);
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
setContacts([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch tags for these contacts
|
||||
const contactIds = data.map((c) => c.id);
|
||||
const { data: contactTags } = await supabase
|
||||
.from('contact_tags')
|
||||
.select('contact_id, tag_id')
|
||||
.in('contact_id', contactIds);
|
||||
|
||||
const tagsByContact: Record<string, string[]> = {};
|
||||
contactTags?.forEach((ct) => {
|
||||
if (!tagsByContact[ct.contact_id]) tagsByContact[ct.contact_id] = [];
|
||||
tagsByContact[ct.contact_id].push(ct.tag_id);
|
||||
});
|
||||
|
||||
const enriched: ContactWithTags[] = data.map((c) => ({
|
||||
...c,
|
||||
tags: (tagsByContact[c.id] ?? [])
|
||||
.map((tid) => tagsMap[tid])
|
||||
.filter(Boolean),
|
||||
}));
|
||||
|
||||
setContacts(enriched);
|
||||
setLoading(false);
|
||||
}, [supabase, page, search, tagsMap]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTags();
|
||||
}, [fetchTags]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContacts();
|
||||
}, [fetchContacts]);
|
||||
|
||||
// Reset page when search changes
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [search]);
|
||||
|
||||
function openAddForm() {
|
||||
setEditContact(null);
|
||||
setEditContactTags([]);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
async function openEditForm(contact: Contact) {
|
||||
const { data } = await supabase
|
||||
.from('contact_tags')
|
||||
.select('*')
|
||||
.eq('contact_id', contact.id);
|
||||
setEditContact(contact);
|
||||
setEditContactTags(data ?? []);
|
||||
setFormOpen(true);
|
||||
}
|
||||
|
||||
function openDetail(contactId: string) {
|
||||
setDetailContactId(contactId);
|
||||
setDetailOpen(true);
|
||||
}
|
||||
|
||||
function confirmDelete(contact: Contact) {
|
||||
setDeleteTarget(contact);
|
||||
setDeleteConfirmOpen(true);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return;
|
||||
setDeleting(true);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('contacts')
|
||||
.delete()
|
||||
.eq('id', deleteTarget.id);
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to delete contact');
|
||||
} else {
|
||||
toast.success('Contact deleted');
|
||||
fetchContacts();
|
||||
}
|
||||
|
||||
setDeleting(false);
|
||||
setDeleteConfirmOpen(false);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||
const hasNext = page < totalPages - 1;
|
||||
const hasPrev = page > 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">
|
||||
Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setImportOpen(true)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
onClick={openAddForm}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
Add Contact
|
||||
</Button>
|
||||
</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" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search by name, phone, or email..."
|
||||
className="pl-8 bg-slate-900 border-slate-700 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-lg border border-slate-800 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>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={7} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="size-6 animate-spin text-emerald-500" />
|
||||
<p className="text-sm text-slate-500">Loading contacts...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : contacts.length === 0 ? (
|
||||
<TableRow className="border-slate-800">
|
||||
<TableCell colSpan={7} 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.'}
|
||||
</p>
|
||||
{!search && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openAddForm}
|
||||
className="mt-2 border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
Add your first contact
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
contacts.map((contact) => (
|
||||
<TableRow
|
||||
key={contact.id}
|
||||
className="border-slate-800 hover:bg-slate-900/50 cursor-pointer"
|
||||
onClick={() => openDetail(contact.id)}
|
||||
>
|
||||
<TableCell className="text-white font-medium">
|
||||
{contact.name || <span className="text-slate-500 italic">Unnamed</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300 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>
|
||||
<TableCell className="text-slate-400 hidden lg:table-cell text-sm">
|
||||
{contact.company || <span className="text-slate-600">-</span>}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{contact.tags && contact.tags.length > 0 ? (
|
||||
contact.tags.slice(0, 3).map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: tag.color + '20',
|
||||
color: tag.color,
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="text-slate-600 text-xs">-</span>
|
||||
)}
|
||||
{contact.tags && contact.tags.length > 3 && (
|
||||
<span className="text-[10px] text-slate-500">
|
||||
+{contact.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-500 text-xs hidden lg:table-cell">
|
||||
{new Date(contact.created_at).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="text-slate-400 hover:text-white"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="bg-slate-900 border-slate-700"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEditForm(contact);
|
||||
}}
|
||||
className="text-slate-300 focus:bg-slate-800 focus:text-white"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator className="bg-slate-700" />
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
confirmDelete(contact);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-slate-500">
|
||||
Showing {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalCount)} of{' '}
|
||||
{totalCount}
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
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"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-slate-400 px-2">
|
||||
Page {page + 1} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
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"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact Form Dialog */}
|
||||
<ContactForm
|
||||
open={formOpen}
|
||||
onOpenChange={setFormOpen}
|
||||
contact={editContact}
|
||||
contactTags={editContactTags}
|
||||
onSaved={() => {
|
||||
fetchContacts();
|
||||
fetchTags();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Contact Detail Sheet */}
|
||||
<ContactDetailView
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
contactId={detailContactId}
|
||||
onUpdated={fetchContacts}
|
||||
/>
|
||||
|
||||
{/* Import Modal */}
|
||||
<ImportModal
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={fetchContacts}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Delete Contact</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Are you sure you want to delete{' '}
|
||||
<span className="text-slate-200 font-medium">
|
||||
{deleteTarget?.name || deleteTarget?.phone}
|
||||
</span>
|
||||
? This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteConfirmOpen(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting && <Loader2 className="size-4 animate-spin" />}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
428
src/app/(dashboard)/dashboard/page.tsx
Normal file
428
src/app/(dashboard)/dashboard/page.tsx
Normal file
@ -0,0 +1,428 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { Conversation, Pipeline, PipelineStage, Broadcast } from "@/types";
|
||||
import {
|
||||
Users,
|
||||
MessageSquare,
|
||||
Mail,
|
||||
TrendingUp,
|
||||
ArrowRight,
|
||||
Radio,
|
||||
GitBranch,
|
||||
DollarSign,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string) {
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60000);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffMin < 1) return "Just now";
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
if (diffDay < 7) return `${diffDay}d ago`;
|
||||
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ReactNode;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, loading }: StatCardProps) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-medium text-slate-400">{title}</p>
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="mt-3 h-8 w-20 animate-pulse rounded bg-slate-800" />
|
||||
) : (
|
||||
<p className="mt-3 text-3xl font-bold text-white">{value}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PipelineOverviewData {
|
||||
pipeline: Pipeline;
|
||||
stages: (PipelineStage & { dealCount: number; totalValue: number })[];
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const supabase = createClient();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalContacts, setTotalContacts] = useState(0);
|
||||
const [openConversations, setOpenConversations] = useState(0);
|
||||
const [messagesToday, setMessagesToday] = useState(0);
|
||||
const [activeDeals, setActiveDeals] = useState(0);
|
||||
const [recentConversations, setRecentConversations] = useState<Conversation[]>([]);
|
||||
const [pipelineOverviews, setPipelineOverviews] = useState<PipelineOverviewData[]>([]);
|
||||
const [recentBroadcasts, setRecentBroadcasts] = useState<Broadcast[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboardData();
|
||||
}, []);
|
||||
|
||||
async function loadDashboardData() {
|
||||
setLoading(true);
|
||||
|
||||
const todayStart = new Date();
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const [
|
||||
contactsRes,
|
||||
openConvRes,
|
||||
messagesTodayRes,
|
||||
dealsRes,
|
||||
recentConvRes,
|
||||
pipelinesRes,
|
||||
broadcastsRes,
|
||||
] = await Promise.all([
|
||||
supabase.from("contacts").select("id", { count: "exact", head: true }),
|
||||
supabase
|
||||
.from("conversations")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("status", "open"),
|
||||
supabase
|
||||
.from("messages")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.gte("created_at", todayStart.toISOString()),
|
||||
supabase.from("deals").select("id", { count: "exact", head: true }),
|
||||
supabase
|
||||
.from("conversations")
|
||||
.select("*, contact:contacts(*)")
|
||||
.order("last_message_at", { ascending: false })
|
||||
.limit(5),
|
||||
supabase.from("pipelines").select("*").order("created_at"),
|
||||
supabase
|
||||
.from("broadcasts")
|
||||
.select("*")
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(3),
|
||||
]);
|
||||
|
||||
setTotalContacts(contactsRes.count ?? 0);
|
||||
setOpenConversations(openConvRes.count ?? 0);
|
||||
setMessagesToday(messagesTodayRes.count ?? 0);
|
||||
setActiveDeals(dealsRes.count ?? 0);
|
||||
setRecentConversations(recentConvRes.data ?? []);
|
||||
setRecentBroadcasts(broadcastsRes.data ?? []);
|
||||
|
||||
// Load pipeline overviews with stages and deal data
|
||||
if (pipelinesRes.data && pipelinesRes.data.length > 0) {
|
||||
const overviews: PipelineOverviewData[] = [];
|
||||
for (const pipeline of pipelinesRes.data) {
|
||||
const { data: stagesData } = await supabase
|
||||
.from("pipeline_stages")
|
||||
.select("*")
|
||||
.eq("pipeline_id", pipeline.id)
|
||||
.order("position");
|
||||
|
||||
const { data: dealsData } = await supabase
|
||||
.from("deals")
|
||||
.select("stage_id, value")
|
||||
.eq("pipeline_id", pipeline.id);
|
||||
|
||||
const enrichedStages = (stagesData || []).map((stage) => {
|
||||
const stageDeals = (dealsData || []).filter(
|
||||
(d) => d.stage_id === stage.id
|
||||
);
|
||||
return {
|
||||
...stage,
|
||||
dealCount: stageDeals.length,
|
||||
totalValue: stageDeals.reduce(
|
||||
(sum: number, d: { value: number }) => sum + d.value,
|
||||
0
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
overviews.push({ pipeline, stages: enrichedStages });
|
||||
}
|
||||
setPipelineOverviews(overviews);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function getBroadcastStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case "sent":
|
||||
return "bg-emerald-500/10 text-emerald-400";
|
||||
case "sending":
|
||||
return "bg-blue-500/10 text-blue-400";
|
||||
case "scheduled":
|
||||
return "bg-yellow-500/10 text-yellow-400";
|
||||
case "draft":
|
||||
return "bg-slate-500/10 text-slate-400";
|
||||
case "failed":
|
||||
return "bg-red-500/10 text-red-400";
|
||||
default:
|
||||
return "bg-slate-500/10 text-slate-400";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Overview of your WhatsApp CRM activity
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row 1: Stat Cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="Total Contacts"
|
||||
value={totalContacts.toLocaleString()}
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Open Conversations"
|
||||
value={openConversations.toLocaleString()}
|
||||
icon={<MessageSquare className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Messages Today"
|
||||
value={messagesToday.toLocaleString()}
|
||||
icon={<Mail className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
/>
|
||||
<StatCard
|
||||
title="Active Deals"
|
||||
value={activeDeals.toLocaleString()}
|
||||
icon={<TrendingUp className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Recent Conversations */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-emerald-500" />
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
Recent Conversations
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => router.push("/dashboard/inbox")}
|
||||
className="flex items-center gap-1 text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
|
||||
>
|
||||
View all
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-800">
|
||||
{loading ? (
|
||||
Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-5 py-3">
|
||||
<div className="h-9 w-9 animate-pulse rounded-full bg-slate-800" />
|
||||
<div className="flex-1">
|
||||
<div className="h-4 w-28 animate-pulse rounded bg-slate-800" />
|
||||
<div className="mt-1.5 h-3 w-48 animate-pulse rounded bg-slate-800" />
|
||||
</div>
|
||||
<div className="h-3 w-12 animate-pulse rounded bg-slate-800" />
|
||||
</div>
|
||||
))
|
||||
) : recentConversations.length === 0 ? (
|
||||
<div className="px-5 py-8 text-center text-sm text-slate-500">
|
||||
No conversations yet
|
||||
</div>
|
||||
) : (
|
||||
recentConversations.map((conv) => (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => router.push("/dashboard/inbox")}
|
||||
className="flex w-full items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-slate-800/50"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
||||
{conv.contact?.name?.charAt(0)?.toUpperCase() ||
|
||||
conv.contact?.phone?.slice(-2) ||
|
||||
"?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{conv.contact?.name || conv.contact?.phone || "Unknown"}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400 truncate">
|
||||
{conv.last_message_text || "No messages"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||
<span className="text-xs text-slate-500">
|
||||
{conv.last_message_at
|
||||
? formatRelativeTime(conv.last_message_at)
|
||||
: ""}
|
||||
</span>
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-emerald-500 px-1 text-[10px] font-medium text-white">
|
||||
{conv.unread_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: Pipeline Overview */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center gap-2 border-b border-slate-800 px-5 py-4">
|
||||
<GitBranch className="h-4 w-4 text-emerald-500" />
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
Pipeline Overview
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2].map((i) => (
|
||||
<div key={i}>
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-slate-800" />
|
||||
<div className="mt-3 flex gap-2">
|
||||
{[1, 2, 3, 4].map((j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="h-16 flex-1 animate-pulse rounded-lg bg-slate-800/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : pipelineOverviews.length === 0 ? (
|
||||
<div className="py-6 text-center text-sm text-slate-500">
|
||||
No pipelines created yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{pipelineOverviews.map(({ pipeline, stages }) => (
|
||||
<div key={pipeline.id}>
|
||||
<h3 className="text-sm font-medium text-white mb-3">
|
||||
{pipeline.name}
|
||||
</h3>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||
{stages.map((stage) => (
|
||||
<div
|
||||
key={stage.id}
|
||||
className="min-w-[140px] flex-1 rounded-lg border border-slate-800 bg-slate-800/30 p-3"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<div
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: stage.color }}
|
||||
/>
|
||||
<span className="text-xs font-medium text-slate-300 truncate">
|
||||
{stage.name}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-lg font-bold text-white">
|
||||
{stage.dealCount}
|
||||
</p>
|
||||
{stage.totalValue > 0 && (
|
||||
<p className="flex items-center gap-1 text-xs text-emerald-400 mt-0.5">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
{formatCurrency(stage.totalValue)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: Recent Broadcasts */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||
<div className="flex items-center gap-2 border-b border-slate-800 px-5 py-4">
|
||||
<Radio className="h-4 w-4 text-emerald-500" />
|
||||
<h2 className="text-sm font-semibold text-white">
|
||||
Recent Broadcasts
|
||||
</h2>
|
||||
</div>
|
||||
<div className="divide-y divide-slate-800">
|
||||
{loading ? (
|
||||
Array.from({ length: 2 }).map((_, i) => (
|
||||
<div key={i} className="flex items-center gap-3 px-5 py-3">
|
||||
<div className="flex-1">
|
||||
<div className="h-4 w-36 animate-pulse rounded bg-slate-800" />
|
||||
<div className="mt-1.5 h-3 w-24 animate-pulse rounded bg-slate-800" />
|
||||
</div>
|
||||
<div className="h-5 w-16 animate-pulse rounded-full bg-slate-800" />
|
||||
</div>
|
||||
))
|
||||
) : recentBroadcasts.length === 0 ? (
|
||||
<div className="px-5 py-8 text-center text-sm text-slate-500">
|
||||
No broadcasts yet
|
||||
</div>
|
||||
) : (
|
||||
recentBroadcasts.map((broadcast) => (
|
||||
<div
|
||||
key={broadcast.id}
|
||||
className="flex items-center gap-4 px-5 py-3"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800 text-slate-400">
|
||||
<Radio className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{broadcast.name}
|
||||
</p>
|
||||
<div className="mt-0.5 flex items-center gap-3 text-xs text-slate-400">
|
||||
<span>
|
||||
{broadcast.sent_count}/{broadcast.total_recipients} sent
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
{formatRelativeTime(broadcast.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2.5 py-0.5 text-xs font-medium capitalize ${getBroadcastStatusColor(
|
||||
broadcast.status
|
||||
)}`}
|
||||
>
|
||||
{broadcast.status}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
204
src/app/(dashboard)/inbox/page.tsx
Normal file
204
src/app/(dashboard)/inbox/page.tsx
Normal file
@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
|
||||
import { useRealtime } from "@/hooks/use-realtime";
|
||||
import { ConversationList } from "@/components/inbox/conversation-list";
|
||||
import { MessageThread } from "@/components/inbox/message-thread";
|
||||
import { ContactSidebar } from "@/components/inbox/contact-sidebar";
|
||||
import { toast } from "sonner";
|
||||
import { Wifi, WifiOff } from "lucide-react";
|
||||
|
||||
export default function InboxPage() {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [activeConversation, setActiveConversation] =
|
||||
useState<Conversation | null>(null);
|
||||
const [activeContact, setActiveContact] = useState<Contact | null>(null);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [whatsappConnected, setWhatsappConnected] = useState<boolean | null>(
|
||||
null
|
||||
);
|
||||
|
||||
// Check WhatsApp connection status on mount
|
||||
useEffect(() => {
|
||||
const checkConnection = async () => {
|
||||
const supabase = createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
if (!user) return;
|
||||
|
||||
const { data } = await supabase
|
||||
.from("whatsapp_configs")
|
||||
.select("status")
|
||||
.eq("user_id", user.id)
|
||||
.single();
|
||||
|
||||
setWhatsappConnected(data?.status === "connected");
|
||||
};
|
||||
|
||||
checkConnection();
|
||||
}, []);
|
||||
|
||||
// Handle realtime message events
|
||||
const handleMessageEvent = useCallback(
|
||||
(event: { eventType: string; new: Message; old: Partial<Message> }) => {
|
||||
const newMsg = event.new;
|
||||
|
||||
if (event.eventType === "INSERT") {
|
||||
// Add to messages if it belongs to active conversation
|
||||
if (
|
||||
activeConversation &&
|
||||
newMsg.conversation_id === activeConversation.id
|
||||
) {
|
||||
setMessages((prev) => {
|
||||
// Avoid duplicates
|
||||
if (prev.some((m) => m.id === newMsg.id)) return prev;
|
||||
// Replace optimistic message if it exists
|
||||
const withoutOptimistic = prev.filter(
|
||||
(m) => !m.id.startsWith("temp-")
|
||||
);
|
||||
return [...withoutOptimistic, newMsg];
|
||||
});
|
||||
}
|
||||
|
||||
// Update conversation list preview
|
||||
setConversations((prev) =>
|
||||
prev.map((c) =>
|
||||
c.id === newMsg.conversation_id
|
||||
? {
|
||||
...c,
|
||||
last_message_text: newMsg.content_text ?? "",
|
||||
last_message_at: newMsg.created_at,
|
||||
unread_count:
|
||||
activeConversation?.id === newMsg.conversation_id
|
||||
? 0
|
||||
: c.unread_count + 1,
|
||||
}
|
||||
: c
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (event.eventType === "UPDATE") {
|
||||
// Update message status
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === newMsg.id ? { ...m, ...newMsg } : m))
|
||||
);
|
||||
}
|
||||
},
|
||||
[activeConversation]
|
||||
);
|
||||
|
||||
// Handle realtime conversation events
|
||||
const handleConversationEvent = useCallback(
|
||||
(event: {
|
||||
eventType: string;
|
||||
new: Conversation;
|
||||
old: Partial<Conversation>;
|
||||
}) => {
|
||||
const conv = event.new;
|
||||
|
||||
if (event.eventType === "INSERT") {
|
||||
setConversations((prev) => [conv, ...prev]);
|
||||
}
|
||||
|
||||
if (event.eventType === "UPDATE") {
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.id === conv.id ? { ...c, ...conv } : c))
|
||||
);
|
||||
|
||||
// Update active conversation if it changed
|
||||
if (activeConversation && conv.id === activeConversation.id) {
|
||||
setActiveConversation((prev) =>
|
||||
prev ? { ...prev, ...conv } : prev
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeConversation]
|
||||
);
|
||||
|
||||
// Subscribe to realtime
|
||||
useRealtime({
|
||||
channelName: "inbox-realtime",
|
||||
onMessageEvent: handleMessageEvent,
|
||||
onConversationEvent: handleConversationEvent,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const handleConversationsLoaded = useCallback(
|
||||
(loaded: Conversation[]) => {
|
||||
setConversations(loaded);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSelectConversation = useCallback((conv: Conversation) => {
|
||||
setActiveConversation(conv);
|
||||
setActiveContact(conv.contact ?? null);
|
||||
setMessages([]);
|
||||
}, []);
|
||||
|
||||
const handleMessagesLoaded = useCallback((loaded: Message[]) => {
|
||||
setMessages(loaded);
|
||||
}, []);
|
||||
|
||||
const handleNewMessage = useCallback((msg: Message) => {
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||
return [...prev, msg];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
(conversationId: string, status: ConversationStatus) => {
|
||||
setConversations((prev) =>
|
||||
prev.map((c) => (c.id === conversationId ? { ...c, status } : c))
|
||||
);
|
||||
if (activeConversation?.id === conversationId) {
|
||||
setActiveConversation((prev) => (prev ? { ...prev, status } : prev));
|
||||
}
|
||||
},
|
||||
[activeConversation]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||
{/* WhatsApp connection banner */}
|
||||
{whatsappConnected === false && (
|
||||
<div className="absolute left-60 right-0 top-14 z-10 flex items-center justify-center gap-2 bg-amber-500/10 px-4 py-2">
|
||||
<WifiOff className="h-4 w-4 text-amber-400" />
|
||||
<p className="text-xs text-amber-400">
|
||||
WhatsApp is not connected. Go to Settings to connect your account.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Left panel: Conversation list */}
|
||||
<ConversationList
|
||||
activeConversationId={activeConversation?.id ?? null}
|
||||
onSelect={handleSelectConversation}
|
||||
conversations={conversations}
|
||||
onConversationsLoaded={handleConversationsLoaded}
|
||||
/>
|
||||
|
||||
{/* Center panel: Message thread */}
|
||||
<MessageThread
|
||||
conversation={activeConversation}
|
||||
contact={activeContact}
|
||||
messages={messages}
|
||||
onMessagesLoaded={handleMessagesLoaded}
|
||||
onNewMessage={handleNewMessage}
|
||||
onStatusChange={handleStatusChange}
|
||||
/>
|
||||
|
||||
{/* Right panel: Contact sidebar (hidden on small screens) */}
|
||||
<div className="hidden lg:block">
|
||||
<ContactSidebar contact={activeContact} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
47
src/app/(dashboard)/layout.tsx
Normal file
47
src/app/(dashboard)/layout.tsx
Normal file
@ -0,0 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { Sidebar } from "@/components/layout/sidebar";
|
||||
import { Header } from "@/components/layout/header";
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { user, loading } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && !user) {
|
||||
router.push("/login");
|
||||
}
|
||||
}, [user, loading, router]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-slate-950">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
<p className="text-sm text-slate-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-slate-950">
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
301
src/app/(dashboard)/pipelines/page.tsx
Normal file
301
src/app/(dashboard)/pipelines/page.tsx
Normal file
@ -0,0 +1,301 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { Pipeline, PipelineStage } from "@/types";
|
||||
import { PipelineBoard } from "@/components/pipelines/pipeline-board";
|
||||
import { PipelineSettings } from "@/components/pipelines/pipeline-settings";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
GitBranch,
|
||||
Plus,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
} from "lucide-react";
|
||||
|
||||
const DEFAULT_STAGES = [
|
||||
{ name: "Lead", color: "#6366f1", position: 0 },
|
||||
{ name: "Qualified", color: "#8b5cf6", position: 1 },
|
||||
{ name: "Proposal", color: "#f97316", position: 2 },
|
||||
{ name: "Negotiation", color: "#eab308", position: 3 },
|
||||
{ name: "Won", color: "#22c55e", position: 4 },
|
||||
];
|
||||
|
||||
export default function PipelinesPage() {
|
||||
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
||||
const [stages, setStages] = useState<PipelineStage[]>([]);
|
||||
const [selectedPipelineId, setSelectedPipelineId] = useState<string>("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [newPipelineOpen, setNewPipelineOpen] = useState(false);
|
||||
const [newPipelineName, setNewPipelineName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
useEffect(() => {
|
||||
loadPipelines();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedPipelineId) {
|
||||
loadStages(selectedPipelineId);
|
||||
}
|
||||
}, [selectedPipelineId]);
|
||||
|
||||
async function loadPipelines() {
|
||||
setLoading(true);
|
||||
const { data } = await supabase
|
||||
.from("pipelines")
|
||||
.select("*")
|
||||
.order("created_at");
|
||||
if (data && data.length > 0) {
|
||||
setPipelines(data);
|
||||
if (!selectedPipelineId || !data.find((p) => p.id === selectedPipelineId)) {
|
||||
setSelectedPipelineId(data[0].id);
|
||||
}
|
||||
} else {
|
||||
setPipelines([]);
|
||||
setSelectedPipelineId("");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function loadStages(pipelineId: string) {
|
||||
const { data } = await supabase
|
||||
.from("pipeline_stages")
|
||||
.select("*")
|
||||
.eq("pipeline_id", pipelineId)
|
||||
.order("position");
|
||||
if (data) setStages(data);
|
||||
}
|
||||
|
||||
async function handleCreatePipeline() {
|
||||
if (!newPipelineName.trim()) return;
|
||||
setCreating(true);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: pipeline } = await supabase
|
||||
.from("pipelines")
|
||||
.insert({ user_id: user.id, name: newPipelineName.trim() })
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (pipeline) {
|
||||
const stageInserts = DEFAULT_STAGES.map((s) => ({
|
||||
pipeline_id: pipeline.id,
|
||||
name: s.name,
|
||||
color: s.color,
|
||||
position: s.position,
|
||||
}));
|
||||
await supabase.from("pipeline_stages").insert(stageInserts);
|
||||
|
||||
setNewPipelineName("");
|
||||
setNewPipelineOpen(false);
|
||||
setSelectedPipelineId(pipeline.id);
|
||||
await loadPipelines();
|
||||
}
|
||||
|
||||
setCreating(false);
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
loadPipelines();
|
||||
if (selectedPipelineId) {
|
||||
loadStages(selectedPipelineId);
|
||||
}
|
||||
}
|
||||
|
||||
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="h-7 w-32 animate-pulse rounded bg-slate-800" />
|
||||
<div className="mt-2 h-4 w-48 animate-pulse rounded bg-slate-800" />
|
||||
</div>
|
||||
<div className="h-8 w-28 animate-pulse rounded-lg bg-slate-800" />
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-96 w-72 animate-pulse rounded-xl bg-slate-800/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Pipelines</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Manage your deals and sales pipeline
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedPipeline && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="text-slate-400 hover:text-white"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => setNewPipelineOpen(true)}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
New Pipeline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pipeline selector */}
|
||||
{pipelines.length > 1 && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
className="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"
|
||||
>
|
||||
<GitBranch className="h-4 w-4 text-emerald-500" />
|
||||
{selectedPipeline?.name || "Select Pipeline"}
|
||||
<ChevronDown className="h-4 w-4 text-slate-400" />
|
||||
</button>
|
||||
{dropdownOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
onClick={() => setDropdownOpen(false)}
|
||||
/>
|
||||
<div className="absolute top-full left-0 z-20 mt-1 w-56 rounded-lg border border-slate-700 bg-slate-900 py-1 shadow-lg">
|
||||
{pipelines.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => {
|
||||
setSelectedPipelineId(p.id);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors ${
|
||||
p.id === selectedPipelineId
|
||||
? "bg-emerald-500/10 text-emerald-400"
|
||||
: "text-slate-300 hover:bg-slate-800"
|
||||
}`}
|
||||
>
|
||||
<GitBranch className="h-3.5 w-3.5" />
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Board or empty state */}
|
||||
{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">
|
||||
No pipelines yet
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Create your first pipeline to start tracking deals
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setNewPipelineOpen(true)}
|
||||
className="mt-4 bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Create Pipeline
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<PipelineBoard
|
||||
pipelineId={selectedPipelineId}
|
||||
stages={stages}
|
||||
onStagesChange={() => loadStages(selectedPipelineId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* New Pipeline Dialog */}
|
||||
<Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}>
|
||||
<DialogContent className="sm:max-w-sm bg-slate-900 border-slate-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">New Pipeline</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2">
|
||||
<Label className="text-slate-300">Pipeline Name</Label>
|
||||
<Input
|
||||
value={newPipelineName}
|
||||
onChange={(e) => setNewPipelineName(e.target.value)}
|
||||
placeholder="e.g., Sales Pipeline"
|
||||
className="mt-2 bg-slate-800 border-slate-700 text-white"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleCreatePipeline();
|
||||
}}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
Default stages (Lead, Qualified, Proposal, Negotiation, Won) will
|
||||
be created automatically.
|
||||
</p>
|
||||
</div>
|
||||
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setNewPipelineOpen(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreatePipeline}
|
||||
disabled={creating || !newPipelineName.trim()}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
{creating ? "Creating..." : "Create Pipeline"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Pipeline Settings */}
|
||||
{selectedPipeline && (
|
||||
<PipelineSettings
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
pipeline={selectedPipeline}
|
||||
stages={stages}
|
||||
onUpdated={handleRefresh}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/app/(dashboard)/settings/page.tsx
Normal file
49
src/app/(dashboard)/settings/page.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { Settings, MessageSquare, Tag } from 'lucide-react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
||||
import { TemplateManager } from '@/components/settings/template-manager';
|
||||
import { TagManager } from '@/components/settings/tag-manager';
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Manage your WhatsApp integration, message templates, and tags.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="whatsapp">
|
||||
<TabsList className="bg-slate-900 border border-slate-700">
|
||||
<TabsTrigger value="whatsapp" className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400">
|
||||
<Settings className="size-4" />
|
||||
WhatsApp Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="templates" className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400">
|
||||
<MessageSquare className="size-4" />
|
||||
Templates
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="tags" className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400">
|
||||
<Tag className="size-4" />
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="whatsapp">
|
||||
<WhatsAppConfig />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="templates">
|
||||
<TemplateManager />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tags">
|
||||
<TagManager />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
123
src/app/api/whatsapp/broadcast/route.ts
Normal file
123
src/app/api/whatsapp/broadcast/route.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||
import { sanitizePhoneForMeta, isValidE164 } from '@/lib/whatsapp/phone-utils'
|
||||
|
||||
interface BroadcastResult {
|
||||
phone: string
|
||||
status: 'sent' | 'failed'
|
||||
whatsapp_message_id?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { phone_numbers, template_name, template_language, template_params } = body
|
||||
|
||||
if (!phone_numbers || !Array.isArray(phone_numbers) || phone_numbers.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'phone_numbers array is required and must not be empty' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (!template_name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'template_name is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch and decrypt WhatsApp config
|
||||
const { data: config, error: configError } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (configError || !config) {
|
||||
return NextResponse.json(
|
||||
{ error: 'WhatsApp not configured. Please set up your WhatsApp integration first.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const accessToken = decrypt(config.access_token)
|
||||
|
||||
const results: BroadcastResult[] = []
|
||||
let sentCount = 0
|
||||
let failedCount = 0
|
||||
|
||||
for (const phone of phone_numbers) {
|
||||
const sanitized = sanitizePhoneForMeta(phone)
|
||||
|
||||
if (!isValidE164(sanitized)) {
|
||||
results.push({
|
||||
phone,
|
||||
status: 'failed',
|
||||
error: 'Invalid phone number format',
|
||||
})
|
||||
failedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendTemplateMessage(
|
||||
accessToken,
|
||||
config.phone_number_id,
|
||||
sanitized,
|
||||
template_name,
|
||||
template_params || [],
|
||||
template_language || 'en_US'
|
||||
)
|
||||
|
||||
results.push({
|
||||
phone,
|
||||
status: 'sent',
|
||||
whatsapp_message_id: result.messageId,
|
||||
})
|
||||
sentCount++
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
console.error(`Failed to send broadcast to ${phone}:`, error)
|
||||
results.push({
|
||||
phone,
|
||||
status: 'failed',
|
||||
error: errorMessage,
|
||||
})
|
||||
failedCount++
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
total: phone_numbers.length,
|
||||
sent: sentCount,
|
||||
failed: failedCount,
|
||||
results,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in WhatsApp broadcast POST:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to process broadcast' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
98
src/app/api/whatsapp/config/route.ts
Normal file
98
src/app/api/whatsapp/config/route.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { verifyPhoneNumber } from '@/lib/whatsapp/meta-api'
|
||||
import { encrypt } from '@/lib/whatsapp/encryption'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { phone_number_id, waba_id, access_token, verify_token } = body
|
||||
|
||||
if (!access_token || !phone_number_id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'access_token and phone_number_id are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the phone number with Meta API
|
||||
const phoneInfo = await verifyPhoneNumber(access_token, phone_number_id)
|
||||
|
||||
// Encrypt sensitive tokens
|
||||
const encryptedAccessToken = encrypt(access_token)
|
||||
const encryptedVerifyToken = verify_token ? encrypt(verify_token) : null
|
||||
|
||||
// Check if config already exists for this user
|
||||
const { data: existing } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.select('id')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (existing) {
|
||||
// Update existing config
|
||||
const { error: updateError } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.update({
|
||||
phone_number_id,
|
||||
waba_id: waba_id || null,
|
||||
access_token: encryptedAccessToken,
|
||||
verify_token: encryptedVerifyToken,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('user_id', user.id)
|
||||
|
||||
if (updateError) {
|
||||
console.error('Error updating whatsapp_config:', updateError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to update configuration' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Insert new config
|
||||
const { error: insertError } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
phone_number_id,
|
||||
waba_id: waba_id || null,
|
||||
access_token: encryptedAccessToken,
|
||||
verify_token: encryptedVerifyToken,
|
||||
})
|
||||
|
||||
if (insertError) {
|
||||
console.error('Error inserting whatsapp_config:', insertError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to save configuration' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
phone_info: phoneInfo,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in WhatsApp config POST:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
70
src/app/api/whatsapp/media/[mediaId]/route.ts
Normal file
70
src/app/api/whatsapp/media/[mediaId]/route.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
|
||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ mediaId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { mediaId } = await params
|
||||
|
||||
if (!mediaId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Media ID is required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch and decrypt WhatsApp config
|
||||
const { data: config, error: configError } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (configError || !config) {
|
||||
return NextResponse.json(
|
||||
{ error: 'WhatsApp not configured' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const accessToken = decrypt(config.access_token)
|
||||
|
||||
// Get the download URL from Meta
|
||||
const mediaInfo = await getMediaUrl(mediaId, accessToken)
|
||||
|
||||
// Download the binary data
|
||||
const { buffer, contentType } = await downloadMedia(mediaInfo.url, accessToken)
|
||||
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': contentType || mediaInfo.mimeType || 'application/octet-stream',
|
||||
'Cache-Control': 'public, max-age=86400',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in WhatsApp media GET:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch media' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
171
src/app/api/whatsapp/send/route.ts
Normal file
171
src/app/api/whatsapp/send/route.ts
Normal file
@ -0,0 +1,171 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@/lib/supabase/server'
|
||||
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||
import { sanitizePhoneForMeta, isValidE164 } from '@/lib/whatsapp/phone-utils'
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const supabase = await createClient()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error: authError,
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
if (authError || !user) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const {
|
||||
conversation_id,
|
||||
message_type,
|
||||
content_text,
|
||||
media_url,
|
||||
template_name,
|
||||
template_params,
|
||||
} = body
|
||||
|
||||
if (!conversation_id || !message_type) {
|
||||
return NextResponse.json(
|
||||
{ error: 'conversation_id and message_type are required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (message_type === 'text' && !content_text) {
|
||||
return NextResponse.json(
|
||||
{ error: 'content_text is required for text messages' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
if (message_type === 'template' && !template_name) {
|
||||
return NextResponse.json(
|
||||
{ error: 'template_name is required for template messages' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch conversation and contact
|
||||
const { data: conversation, error: convError } = await supabase
|
||||
.from('conversations')
|
||||
.select('*, contact:contacts(*)')
|
||||
.eq('id', conversation_id)
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (convError || !conversation) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Conversation not found' },
|
||||
{ status: 404 }
|
||||
)
|
||||
}
|
||||
|
||||
const contact = conversation.contact
|
||||
if (!contact?.phone) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Contact phone number not found' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Sanitize and validate phone
|
||||
const sanitizedPhone = sanitizePhoneForMeta(contact.phone)
|
||||
if (!isValidE164(sanitizedPhone)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid phone number format' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch and decrypt WhatsApp config
|
||||
const { data: config, error: configError } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single()
|
||||
|
||||
if (configError || !config) {
|
||||
return NextResponse.json(
|
||||
{ error: 'WhatsApp not configured. Please set up your WhatsApp integration first.' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const accessToken = decrypt(config.access_token)
|
||||
|
||||
// Send via Meta API
|
||||
let waMessageId: string
|
||||
|
||||
if (message_type === 'template') {
|
||||
const result = await sendTemplateMessage(
|
||||
accessToken,
|
||||
config.phone_number_id,
|
||||
sanitizedPhone,
|
||||
template_name,
|
||||
template_params || []
|
||||
)
|
||||
waMessageId = result.messageId
|
||||
} else {
|
||||
const result = await sendTextMessage(
|
||||
accessToken,
|
||||
config.phone_number_id,
|
||||
sanitizedPhone,
|
||||
content_text
|
||||
)
|
||||
waMessageId = result.messageId
|
||||
}
|
||||
|
||||
// Insert message into DB
|
||||
const { data: messageRecord, error: msgError } = await supabase
|
||||
.from('messages')
|
||||
.insert({
|
||||
conversation_id,
|
||||
user_id: user.id,
|
||||
whatsapp_message_id: waMessageId,
|
||||
direction: 'outbound',
|
||||
message_type,
|
||||
content_text: content_text || null,
|
||||
media_url: media_url || null,
|
||||
status: 'sent',
|
||||
timestamp: new Date().toISOString(),
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (msgError) {
|
||||
console.error('Error inserting sent message:', msgError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Message sent but failed to save to database' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
|
||||
// Update conversation
|
||||
await supabase
|
||||
.from('conversations')
|
||||
.update({
|
||||
last_message_text: content_text || `[${message_type}]`,
|
||||
last_message_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', conversation_id)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message_id: messageRecord.id,
|
||||
whatsapp_message_id: waMessageId,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error in WhatsApp send POST:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to send message' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
450
src/app/api/whatsapp/webhook/route.ts
Normal file
450
src/app/api/whatsapp/webhook/route.ts
Normal file
@ -0,0 +1,450 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
|
||||
import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils'
|
||||
|
||||
// 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
|
||||
function supabaseAdmin() {
|
||||
if (!_adminClient) {
|
||||
_adminClient = createClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||
)
|
||||
}
|
||||
return _adminClient
|
||||
}
|
||||
|
||||
interface WhatsAppMessage {
|
||||
id: string
|
||||
from: string
|
||||
timestamp: string
|
||||
type: string
|
||||
text?: { body: string }
|
||||
image?: { id: string; mime_type: string; caption?: string }
|
||||
video?: { id: string; mime_type: string; caption?: string }
|
||||
document?: { id: string; mime_type: string; filename?: string; caption?: string }
|
||||
audio?: { id: string; mime_type: string }
|
||||
location?: { latitude: number; longitude: number; name?: string; address?: string }
|
||||
reaction?: { message_id: string; emoji: string }
|
||||
}
|
||||
|
||||
interface WhatsAppWebhookEntry {
|
||||
id: string
|
||||
changes: Array<{
|
||||
value: {
|
||||
messaging_product: string
|
||||
metadata: {
|
||||
display_phone_number: string
|
||||
phone_number_id: string
|
||||
}
|
||||
contacts?: Array<{
|
||||
profile: { name: string }
|
||||
wa_id: string
|
||||
}>
|
||||
messages?: WhatsAppMessage[]
|
||||
statuses?: Array<{
|
||||
id: string
|
||||
status: string
|
||||
timestamp: string
|
||||
recipient_id: string
|
||||
}>
|
||||
}
|
||||
field: string
|
||||
}>
|
||||
}
|
||||
|
||||
// GET - Webhook verification
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const mode = searchParams.get('hub.mode')
|
||||
const challenge = searchParams.get('hub.challenge')
|
||||
const verifyToken = searchParams.get('hub.verify_token')
|
||||
|
||||
if (mode !== 'subscribe' || !challenge || !verifyToken) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing verification parameters' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch all whatsapp configs to check verify tokens
|
||||
const { data: configs, error: configError } = await supabaseAdmin()
|
||||
.from('whatsapp_config')
|
||||
.select('id, verify_token')
|
||||
|
||||
if (configError || !configs) {
|
||||
console.error('Error fetching configs for verification:', configError)
|
||||
return NextResponse.json(
|
||||
{ error: 'Verification failed' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if any config's verify_token matches
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const matched = configs.some((config: any) => {
|
||||
if (!config.verify_token) return false
|
||||
try {
|
||||
const decrypted = decrypt(config.verify_token)
|
||||
return decrypted === verifyToken
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
if (matched) {
|
||||
// Return challenge as plain text
|
||||
return new Response(challenge, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Verification token mismatch' },
|
||||
{ status: 403 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error in webhook GET verification:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Receive messages
|
||||
export async function POST(request: Request) {
|
||||
// Always return 200 immediately to acknowledge receipt
|
||||
const body = await request.json()
|
||||
|
||||
// Process asynchronously
|
||||
processWebhook(body).catch((error) => {
|
||||
console.error('Error processing webhook:', error)
|
||||
})
|
||||
|
||||
return NextResponse.json({ status: 'received' }, { status: 200 })
|
||||
}
|
||||
|
||||
async function processWebhook(body: { entry?: WhatsAppWebhookEntry[] }) {
|
||||
if (!body.entry) return
|
||||
|
||||
for (const entry of body.entry) {
|
||||
for (const change of entry.changes) {
|
||||
const value = change.value
|
||||
|
||||
// Handle status updates
|
||||
if (value.statuses) {
|
||||
for (const status of value.statuses) {
|
||||
await handleStatusUpdate(status)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incoming messages
|
||||
if (!value.messages || !value.contacts) continue
|
||||
|
||||
const phoneNumberId = value.metadata.phone_number_id
|
||||
|
||||
// Find user's config by phone_number_id
|
||||
const { data: config, error: configError } = await supabaseAdmin()
|
||||
.from('whatsapp_config')
|
||||
.select('*')
|
||||
.eq('phone_number_id', phoneNumberId)
|
||||
.single()
|
||||
|
||||
if (configError || !config) {
|
||||
console.error('No config found for phone_number_id:', phoneNumberId)
|
||||
continue
|
||||
}
|
||||
|
||||
const decryptedAccessToken = decrypt(config.access_token)
|
||||
|
||||
for (let i = 0; i < value.messages.length; i++) {
|
||||
const message = value.messages[i]
|
||||
const contact = value.contacts[i] || value.contacts[0]
|
||||
|
||||
await processMessage(
|
||||
message,
|
||||
contact,
|
||||
config.user_id,
|
||||
decryptedAccessToken
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusUpdate(status: {
|
||||
id: string
|
||||
status: string
|
||||
timestamp: string
|
||||
recipient_id: string
|
||||
}) {
|
||||
const { error } = await supabaseAdmin()
|
||||
.from('messages')
|
||||
.update({
|
||||
status: status.status,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('whatsapp_message_id', status.id)
|
||||
|
||||
if (error) {
|
||||
console.error('Error updating message status:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function processMessage(
|
||||
message: WhatsAppMessage,
|
||||
contact: { profile: { name: string }; wa_id: string },
|
||||
userId: string,
|
||||
accessToken: string
|
||||
) {
|
||||
const senderPhone = normalizePhone(message.from)
|
||||
const contactName = contact.profile.name
|
||||
|
||||
// Parse message content based on type
|
||||
const { contentText, mediaUrl, mediaType } = await parseMessageContent(
|
||||
message,
|
||||
accessToken
|
||||
)
|
||||
|
||||
// Find or create contact
|
||||
const contactRecord = await findOrCreateContact(
|
||||
userId,
|
||||
senderPhone,
|
||||
contactName
|
||||
)
|
||||
if (!contactRecord) return
|
||||
|
||||
// Find or create conversation
|
||||
const conversation = await findOrCreateConversation(
|
||||
userId,
|
||||
contactRecord.id
|
||||
)
|
||||
if (!conversation) return
|
||||
|
||||
// Insert message
|
||||
const { error: msgError } = await supabaseAdmin().from('messages').insert({
|
||||
conversation_id: conversation.id,
|
||||
user_id: userId,
|
||||
whatsapp_message_id: message.id,
|
||||
direction: 'inbound',
|
||||
message_type: message.type,
|
||||
content_text: contentText,
|
||||
media_url: mediaUrl,
|
||||
media_type: mediaType,
|
||||
status: 'received',
|
||||
timestamp: new Date(parseInt(message.timestamp) * 1000).toISOString(),
|
||||
})
|
||||
|
||||
if (msgError) {
|
||||
console.error('Error inserting message:', msgError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update conversation
|
||||
const { error: convError } = await supabaseAdmin()
|
||||
.from('conversations')
|
||||
.update({
|
||||
last_message_text: contentText || `[${message.type}]`,
|
||||
last_message_at: new Date().toISOString(),
|
||||
unread_count: (conversation.unread_count || 0) + 1,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', conversation.id)
|
||||
|
||||
if (convError) {
|
||||
console.error('Error updating conversation:', convError)
|
||||
}
|
||||
}
|
||||
|
||||
async function parseMessageContent(
|
||||
message: WhatsAppMessage,
|
||||
accessToken: string
|
||||
): Promise<{
|
||||
contentText: string | null
|
||||
mediaUrl: string | null
|
||||
mediaType: string | null
|
||||
}> {
|
||||
switch (message.type) {
|
||||
case 'text':
|
||||
return {
|
||||
contentText: message.text?.body || null,
|
||||
mediaUrl: null,
|
||||
mediaType: null,
|
||||
}
|
||||
|
||||
case 'image':
|
||||
if (message.image) {
|
||||
try {
|
||||
await getMediaUrl(accessToken, message.image.id)
|
||||
return {
|
||||
contentText: message.image.caption || null,
|
||||
mediaUrl: `/api/whatsapp/media/${message.image.id}`,
|
||||
mediaType: message.image.mime_type,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying image media:', error)
|
||||
}
|
||||
}
|
||||
return { contentText: message.image?.caption || null, mediaUrl: null, mediaType: null }
|
||||
|
||||
case 'video':
|
||||
if (message.video) {
|
||||
try {
|
||||
await getMediaUrl(accessToken, message.video.id)
|
||||
return {
|
||||
contentText: message.video.caption || null,
|
||||
mediaUrl: `/api/whatsapp/media/${message.video.id}`,
|
||||
mediaType: message.video.mime_type,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying video media:', error)
|
||||
}
|
||||
}
|
||||
return { contentText: message.video?.caption || null, mediaUrl: null, mediaType: null }
|
||||
|
||||
case 'document':
|
||||
if (message.document) {
|
||||
try {
|
||||
await getMediaUrl(accessToken, message.document.id)
|
||||
return {
|
||||
contentText: message.document.caption || message.document.filename || null,
|
||||
mediaUrl: `/api/whatsapp/media/${message.document.id}`,
|
||||
mediaType: message.document.mime_type,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying document media:', error)
|
||||
}
|
||||
}
|
||||
return { contentText: message.document?.filename || null, mediaUrl: null, mediaType: null }
|
||||
|
||||
case 'audio':
|
||||
if (message.audio) {
|
||||
try {
|
||||
await getMediaUrl(accessToken, message.audio.id)
|
||||
return {
|
||||
contentText: null,
|
||||
mediaUrl: `/api/whatsapp/media/${message.audio.id}`,
|
||||
mediaType: message.audio.mime_type,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error verifying audio media:', error)
|
||||
}
|
||||
}
|
||||
return { contentText: null, mediaUrl: null, mediaType: null }
|
||||
|
||||
case 'location':
|
||||
if (message.location) {
|
||||
const loc = message.location
|
||||
const locationText = [loc.name, loc.address, `${loc.latitude},${loc.longitude}`]
|
||||
.filter(Boolean)
|
||||
.join(' - ')
|
||||
return {
|
||||
contentText: locationText,
|
||||
mediaUrl: null,
|
||||
mediaType: null,
|
||||
}
|
||||
}
|
||||
return { contentText: null, mediaUrl: null, mediaType: null }
|
||||
|
||||
case 'reaction':
|
||||
return {
|
||||
contentText: message.reaction?.emoji || null,
|
||||
mediaUrl: null,
|
||||
mediaType: null,
|
||||
}
|
||||
|
||||
default:
|
||||
return {
|
||||
contentText: `[Unsupported message type: ${message.type}]`,
|
||||
mediaUrl: null,
|
||||
mediaType: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function findOrCreateContact(
|
||||
userId: string,
|
||||
phone: string,
|
||||
name: string
|
||||
) {
|
||||
// Look up existing contacts for this user
|
||||
const { data: contacts, error: contactsError } = await supabaseAdmin()
|
||||
.from('contacts')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (contactsError) {
|
||||
console.error('Error fetching contacts:', contactsError)
|
||||
return null
|
||||
}
|
||||
|
||||
// Use phonesMatch for flexible matching
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const existingContact = contacts?.find((c: any) => phonesMatch(c.phone, phone))
|
||||
|
||||
if (existingContact) {
|
||||
// Update name if it changed
|
||||
if (name && name !== existingContact.name) {
|
||||
await supabaseAdmin()
|
||||
.from('contacts')
|
||||
.update({ name, updated_at: new Date().toISOString() })
|
||||
.eq('id', existingContact.id)
|
||||
}
|
||||
return existingContact
|
||||
}
|
||||
|
||||
// Create new contact
|
||||
const { data: newContact, error: createError } = await supabaseAdmin()
|
||||
.from('contacts')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
phone,
|
||||
name: name || phone,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (createError) {
|
||||
console.error('Error creating contact:', createError)
|
||||
return null
|
||||
}
|
||||
|
||||
return newContact
|
||||
}
|
||||
|
||||
async function findOrCreateConversation(userId: string, contactId: string) {
|
||||
// Look for existing conversation
|
||||
const { data: existing, error: findError } = await supabaseAdmin()
|
||||
.from('conversations')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.eq('contact_id', contactId)
|
||||
.single()
|
||||
|
||||
if (!findError && existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
// Create new conversation
|
||||
const { data: newConv, error: createError } = await supabaseAdmin()
|
||||
.from('conversations')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
contact_id: contactId,
|
||||
})
|
||||
.select()
|
||||
.single()
|
||||
|
||||
if (createError) {
|
||||
console.error('Error creating conversation:', createError)
|
||||
return null
|
||||
}
|
||||
|
||||
return newConv
|
||||
}
|
||||
@ -49,72 +49,38 @@
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--background: oklch(0.13 0.01 260);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.18 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.696 0.17 162);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.22 0.01 260);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.22 0.01 260);
|
||||
--muted-foreground: oklch(0.65 0.01 260);
|
||||
--accent: oklch(0.22 0.01 260);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--border: oklch(0.28 0.01 260);
|
||||
--input: oklch(0.28 0.01 260);
|
||||
--ring: oklch(0.696 0.17 162);
|
||||
--chart-1: oklch(0.696 0.17 162);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar: oklch(0.16 0.01 260);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary: oklch(0.696 0.17 162);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent: oklch(0.22 0.01 260);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
--sidebar-border: oklch(0.28 0.01 260);
|
||||
--sidebar-ring: oklch(0.696 0.17 162);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@ -1,20 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter } from "next/font/google";
|
||||
import { Toaster } from "sonner";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const inter = Inter({
|
||||
variable: "--font-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "WaCRM - WhatsApp CRM",
|
||||
description: "WhatsApp CRM for managing conversations, contacts, and pipelines",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@ -23,11 +19,21 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<html lang="en" className={`${inter.variable} h-full antialiased`}>
|
||||
<body className="min-h-full bg-slate-950 text-white font-sans">
|
||||
{children}
|
||||
<Toaster
|
||||
theme="dark"
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "rgb(30 41 59)",
|
||||
border: "1px solid rgb(51 65 85)",
|
||||
color: "white",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,65 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
134
src/components/broadcasts/step1-choose-template.tsx
Normal file
134
src/components/broadcasts/step1-choose-template.tsx
Normal file
@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { MessageTemplate } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2, FileText, ArrowRight } from 'lucide-react';
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
||||
Utility: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||
Authentication: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
|
||||
};
|
||||
|
||||
interface Step1Props {
|
||||
selectedTemplate: MessageTemplate | null;
|
||||
onSelect: (template: MessageTemplate) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack }: Step1Props) {
|
||||
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchTemplates() {
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data, error: fetchError } = await supabase
|
||||
.from('message_templates')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (fetchError) throw fetchError;
|
||||
setTemplates(data ?? []);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load templates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||
<p className="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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">
|
||||
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>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{templates.map((template) => {
|
||||
const isSelected = selectedTemplate?.id === template.id;
|
||||
const catColor = categoryColors[template.category] ?? categoryColors.Utility;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={template.id}
|
||||
onClick={() => onSelect(template)}
|
||||
className={`flex flex-col gap-3 rounded-xl border p-4 text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-emerald-500 bg-emerald-500/5 ring-1 ring-emerald-500/30'
|
||||
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700 hover:bg-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="text-sm font-medium text-white">{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">
|
||||
<span>{template.language ?? 'en_US'}</span>
|
||||
{template.status && (
|
||||
<>
|
||||
<span>-</span>
|
||||
<span>{template.status}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={!selectedTemplate}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
208
src/components/broadcasts/step2-select-audience.tsx
Normal file
208
src/components/broadcasts/step2-select-audience.tsx
Normal file
@ -0,0 +1,208 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Tag } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Users, Tags, Filter, Upload, Loader2, ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
|
||||
type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv';
|
||||
|
||||
interface AudienceConfig {
|
||||
type: AudienceType;
|
||||
tagIds?: string[];
|
||||
csvContacts?: { phone: string; name?: string }[];
|
||||
}
|
||||
|
||||
interface Step2Props {
|
||||
audience: AudienceConfig;
|
||||
onUpdate: (audience: AudienceConfig) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const audienceOptions: { type: AudienceType; label: string; description: string; icon: typeof Users }[] = [
|
||||
{ type: 'all', label: 'All Contacts', description: 'Send to every contact in your database', icon: Users },
|
||||
{ type: 'tags', label: 'Filter by Tags', description: 'Target contacts with specific tags', icon: Tags },
|
||||
{ type: 'custom_field', label: 'Custom Field', description: 'Filter by a custom field value', icon: Filter },
|
||||
{ type: 'csv', label: 'Upload CSV', description: 'Upload a list of phone numbers', icon: Upload },
|
||||
];
|
||||
|
||||
export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step2Props) {
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [loadingTags, setLoadingTags] = useState(false);
|
||||
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
|
||||
const [loadingCount, setLoadingCount] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (audience.type === 'tags') {
|
||||
async function fetchTags() {
|
||||
setLoadingTags(true);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
const { data } = await supabase.from('tags').select('*').order('name');
|
||||
setTags(data ?? []);
|
||||
} finally {
|
||||
setLoadingTags(false);
|
||||
}
|
||||
}
|
||||
fetchTags();
|
||||
}
|
||||
}, [audience.type]);
|
||||
|
||||
const fetchEstimatedCount = useCallback(async () => {
|
||||
setLoadingCount(true);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
|
||||
if (audience.type === 'all') {
|
||||
const { count } = await supabase
|
||||
.from('contacts')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
setEstimatedCount(count ?? 0);
|
||||
} else if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
|
||||
const { data: contactTags } = await supabase
|
||||
.from('contact_tags')
|
||||
.select('contact_id')
|
||||
.in('tag_id', audience.tagIds);
|
||||
|
||||
const uniqueIds = new Set((contactTags ?? []).map((ct) => ct.contact_id));
|
||||
setEstimatedCount(uniqueIds.size);
|
||||
} else if (audience.type === 'csv' && audience.csvContacts) {
|
||||
setEstimatedCount(audience.csvContacts.length);
|
||||
} else {
|
||||
setEstimatedCount(null);
|
||||
}
|
||||
} finally {
|
||||
setLoadingCount(false);
|
||||
}
|
||||
}, [audience.type, audience.tagIds, audience.csvContacts]);
|
||||
|
||||
useEffect(() => {
|
||||
if (audience.type === 'all' || (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) || (audience.type === 'csv' && audience.csvContacts && audience.csvContacts.length > 0)) {
|
||||
fetchEstimatedCount();
|
||||
} else {
|
||||
setEstimatedCount(null);
|
||||
}
|
||||
}, [audience.type, audience.tagIds, audience.csvContacts, fetchEstimatedCount]);
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
const current = audience.tagIds ?? [];
|
||||
const updated = current.includes(tagId)
|
||||
? current.filter((id) => id !== tagId)
|
||||
: [...current, tagId];
|
||||
onUpdate({ ...audience, tagIds: updated });
|
||||
}
|
||||
|
||||
const isValid =
|
||||
audience.type === 'all' ||
|
||||
(audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) ||
|
||||
(audience.type === 'csv' && audience.csvContacts && audience.csvContacts.length > 0);
|
||||
|
||||
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">Choose who will receive this broadcast.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{audienceOptions.map((option) => {
|
||||
const isSelected = audience.type === option.type;
|
||||
const Icon = option.icon;
|
||||
return (
|
||||
<button
|
||||
key={option.type}
|
||||
onClick={() => onUpdate({ type: option.type, tagIds: option.type === 'tags' ? audience.tagIds : undefined })}
|
||||
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
|
||||
isSelected
|
||||
? 'border-emerald-500 bg-emerald-500/5 ring-1 ring-emerald-500/30'
|
||||
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
|
||||
isSelected ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-800 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<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">{option.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
{loadingTags ? (
|
||||
<Loader2 className="h-5 w-5 animate-spin text-emerald-500" />
|
||||
) : tags.length === 0 ? (
|
||||
<p className="text-xs text-slate-400">No tags found. Create tags in Settings.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => {
|
||||
const isSelected = audience.tagIds?.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
|
||||
isSelected
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
||||
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="mr-1.5 h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
{loadingCount ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-emerald-500" />
|
||||
<span className="text-xs text-slate-400">Calculating...</span>
|
||||
</div>
|
||||
) : estimatedCount !== null ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="h-4 w-4 text-emerald-400" />
|
||||
<span className="text-sm text-white">{estimatedCount.toLocaleString()}</span>
|
||||
<span className="text-xs text-slate-400">estimated recipients</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">Select an audience type to see the estimate.</p>
|
||||
)}
|
||||
</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">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={!isValid}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
189
src/components/broadcasts/step3-personalize.tsx
Normal file
189
src/components/broadcasts/step3-personalize.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { MessageTemplate } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { ArrowLeft, ArrowRight, Eye } from 'lucide-react';
|
||||
|
||||
interface VariableMapping {
|
||||
type: 'static' | 'field';
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface Step3Props {
|
||||
template: MessageTemplate;
|
||||
variables: Record<string, VariableMapping>;
|
||||
onUpdate: (variables: Record<string, VariableMapping>) => void;
|
||||
onNext: () => void;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const contactFields = [
|
||||
{ value: 'name', label: 'Contact Name' },
|
||||
{ value: 'phone', label: 'Phone Number' },
|
||||
{ value: 'email', label: 'Email Address' },
|
||||
{ value: 'company', label: 'Company' },
|
||||
];
|
||||
|
||||
const sampleContact = {
|
||||
name: 'John Doe',
|
||||
phone: '+1234567890',
|
||||
email: 'john@example.com',
|
||||
company: 'Acme Corp',
|
||||
};
|
||||
|
||||
export function Step3Personalize({ template, variables, onUpdate, onNext, onBack }: Step3Props) {
|
||||
// Extract {{1}}, {{2}}, etc. from body_text
|
||||
const placeholders = useMemo(() => {
|
||||
const matches = template.body_text.match(/\{\{(\d+)\}\}/g);
|
||||
if (!matches) return [];
|
||||
return [...new Set(matches)].sort();
|
||||
}, [template.body_text]);
|
||||
|
||||
function updateVariable(key: string, field: Partial<VariableMapping>) {
|
||||
const current = variables[key] ?? { type: 'static', value: '' };
|
||||
onUpdate({
|
||||
...variables,
|
||||
[key]: { ...current, ...field },
|
||||
});
|
||||
}
|
||||
|
||||
// Generate preview with sample data
|
||||
const previewText = useMemo(() => {
|
||||
let text = template.body_text;
|
||||
for (const placeholder of placeholders) {
|
||||
const key = placeholder;
|
||||
const mapping = variables[key];
|
||||
let replacement = placeholder;
|
||||
|
||||
if (mapping) {
|
||||
if (mapping.type === 'static' && mapping.value) {
|
||||
replacement = mapping.value;
|
||||
} else if (mapping.type === 'field' && mapping.value) {
|
||||
replacement = sampleContact[mapping.value as keyof typeof sampleContact] ?? placeholder;
|
||||
}
|
||||
}
|
||||
text = text.replaceAll(placeholder, replacement);
|
||||
}
|
||||
return text;
|
||||
}, [template.body_text, variables, placeholders]);
|
||||
|
||||
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">
|
||||
Map template variables to contact 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">This template has no variables to personalize.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{placeholders.map((placeholder) => {
|
||||
const mapping = variables[placeholder] ?? { type: 'static', value: '' };
|
||||
|
||||
return (
|
||||
<div key={placeholder} className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-md bg-emerald-500/10 px-2 py-0.5 text-xs font-mono font-medium text-emerald-400">
|
||||
{placeholder}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
Mapping Type
|
||||
</label>
|
||||
<Select
|
||||
value={mapping.type}
|
||||
onValueChange={(val) =>
|
||||
updateVariable(placeholder, { type: val as 'static' | 'field', value: '' })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-700 bg-slate-800">
|
||||
<SelectItem value="static">Static Value</SelectItem>
|
||||
<SelectItem value="field">Contact Field</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-slate-400">
|
||||
{mapping.type === 'static' ? 'Value' : 'Field'}
|
||||
</label>
|
||||
{mapping.type === 'static' ? (
|
||||
<Input
|
||||
value={mapping.value}
|
||||
onChange={(e) => updateVariable(placeholder, { value: e.target.value })}
|
||||
placeholder="Enter value..."
|
||||
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={mapping.value || undefined}
|
||||
onValueChange={(val) => updateVariable(placeholder, { value: val || '' })}
|
||||
>
|
||||
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
|
||||
<SelectValue placeholder="Select field..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-slate-700 bg-slate-800">
|
||||
{contactFields.map((field) => (
|
||||
<SelectItem key={field.value} value={field.value}>
|
||||
{field.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Eye className="h-4 w-4 text-emerald-400" />
|
||||
<p className="text-sm font-medium text-white">Preview</p>
|
||||
<span className="text-xs text-slate-500">(with sample data)</span>
|
||||
</div>
|
||||
<div className="rounded-lg bg-slate-800/50 p-3">
|
||||
<p className="whitespace-pre-wrap text-sm text-slate-200">{previewText}</p>
|
||||
</div>
|
||||
</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">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
Next
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
src/components/broadcasts/step4-schedule-send.tsx
Normal file
256
src/components/broadcasts/step4-schedule-send.tsx
Normal file
@ -0,0 +1,256 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { MessageTemplate } from '@/types';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
import { ArrowLeft, Send, Clock, Loader2, Users, Lock } from 'lucide-react';
|
||||
|
||||
interface AudienceConfig {
|
||||
type: string;
|
||||
tagIds?: string[];
|
||||
csvContacts?: { phone: string; name?: string }[];
|
||||
}
|
||||
|
||||
interface Step4Props {
|
||||
name: string;
|
||||
onNameChange: (name: string) => void;
|
||||
template: MessageTemplate;
|
||||
audience: AudienceConfig;
|
||||
onSend: () => void;
|
||||
onBack: () => void;
|
||||
isProcessing: boolean;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export function Step4ScheduleSend({
|
||||
name,
|
||||
onNameChange,
|
||||
template,
|
||||
audience,
|
||||
onSend,
|
||||
onBack,
|
||||
isProcessing,
|
||||
progress,
|
||||
}: Step4Props) {
|
||||
const [timing, setTiming] = useState<'now' | 'later'>('now');
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [estimatedReach, setEstimatedReach] = useState<number>(0);
|
||||
const [loadingReach, setLoadingReach] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
async function calculateReach() {
|
||||
setLoadingReach(true);
|
||||
try {
|
||||
const supabase = createClient();
|
||||
|
||||
if (audience.type === 'all') {
|
||||
const { count } = await supabase
|
||||
.from('contacts')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
setEstimatedReach(count ?? 0);
|
||||
} else if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
|
||||
const { data: contactTags } = await supabase
|
||||
.from('contact_tags')
|
||||
.select('contact_id')
|
||||
.in('tag_id', audience.tagIds);
|
||||
|
||||
const uniqueIds = new Set((contactTags ?? []).map((ct) => ct.contact_id));
|
||||
setEstimatedReach(uniqueIds.size);
|
||||
} else if (audience.type === 'csv' && audience.csvContacts) {
|
||||
setEstimatedReach(audience.csvContacts.length);
|
||||
} else {
|
||||
setEstimatedReach(0);
|
||||
}
|
||||
} finally {
|
||||
setLoadingReach(false);
|
||||
}
|
||||
}
|
||||
|
||||
calculateReach();
|
||||
}, [audience]);
|
||||
|
||||
const audienceLabel =
|
||||
audience.type === 'all'
|
||||
? 'All Contacts'
|
||||
: audience.type === 'tags'
|
||||
? `Tags (${audience.tagIds?.length ?? 0} selected)`
|
||||
: audience.type === 'csv'
|
||||
? 'CSV Upload'
|
||||
: 'Custom';
|
||||
|
||||
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">
|
||||
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>
|
||||
<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"
|
||||
/>
|
||||
</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="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>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Audience</p>
|
||||
<p className="text-white">{audienceLabel}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Estimated Reach</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{loadingReach ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-emerald-500" />
|
||||
) : (
|
||||
<>
|
||||
<Users className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<p className="font-medium text-white">{estimatedReach.toLocaleString()}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-400">Language</p>
|
||||
<p className="text-white">{template.language ?? 'en_US'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delivery Timing */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium text-white">Delivery Timing</p>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<button
|
||||
onClick={() => setTiming('now')}
|
||||
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
|
||||
timing === 'now'
|
||||
? 'border-emerald-500 bg-emerald-500/5 ring-1 ring-emerald-500/30'
|
||||
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700'
|
||||
}`}
|
||||
>
|
||||
<Send className={`mt-0.5 h-4 w-4 ${timing === 'now' ? 'text-emerald-400' : 'text-slate-400'}`} />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white">Send Immediately</p>
|
||||
<p className="mt-0.5 text-xs text-slate-400">Start sending right away</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="relative flex items-start gap-3 rounded-xl border border-slate-800 bg-slate-900/30 p-4 opacity-60">
|
||||
<Clock className="mt-0.5 h-4 w-4 text-slate-500" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-slate-400">Schedule for Later</p>
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-slate-800 px-2 py-0.5 text-[10px] font-medium text-slate-400">
|
||||
<Lock className="h-2.5 w-2.5" />
|
||||
Pro
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-slate-500">Pick a date and time</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Processing overlay */}
|
||||
{isProcessing && (
|
||||
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
||||
<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-emerald-500" />
|
||||
<p className="text-sm font-medium text-white">Sending broadcast...</p>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-emerald-400">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-slate-800">
|
||||
<div
|
||||
className="h-1.5 rounded-full bg-emerald-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onBack}
|
||||
disabled={isProcessing}
|
||||
className="border-slate-700 text-slate-300"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<DialogTrigger
|
||||
render={
|
||||
<Button
|
||||
disabled={!name.trim() || isProcessing}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Send Broadcast
|
||||
</DialogTrigger>
|
||||
<DialogContent className="border-slate-700 bg-slate-900 sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Confirm Broadcast</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
You are about to send this broadcast to{' '}
|
||||
<span className="font-medium text-white">{estimatedReach.toLocaleString()}</span>{' '}
|
||||
contacts using the{' '}
|
||||
<span className="font-medium text-white">{template.name}</span> template.
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className="border-slate-700 text-slate-300"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowConfirm(false);
|
||||
onSend();
|
||||
}}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
Confirm & Send
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
602
src/components/contacts/contact-detail-view.tsx
Normal file
602
src/components/contacts/contact-detail-view.tsx
Normal file
@ -0,0 +1,602 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue } from '@/types';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
} from '@/components/ui/sheet';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Phone,
|
||||
Mail,
|
||||
Building2,
|
||||
Copy,
|
||||
Check,
|
||||
Loader2,
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ContactDetailViewProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
contactId: string | null;
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function ContactDetailView({
|
||||
open,
|
||||
onOpenChange,
|
||||
contactId,
|
||||
onUpdated,
|
||||
}: ContactDetailViewProps) {
|
||||
const supabase = createClient();
|
||||
|
||||
const [contact, setContact] = useState<Contact | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [copiedPhone, setCopiedPhone] = useState(false);
|
||||
|
||||
// Details tab
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editPhone, setEditPhone] = useState('');
|
||||
const [editEmail, setEditEmail] = useState('');
|
||||
const [editCompany, setEditCompany] = useState('');
|
||||
const [savingDetails, setSavingDetails] = useState(false);
|
||||
|
||||
// Tags tab
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
const [contactTagIds, setContactTagIds] = useState<string[]>([]);
|
||||
const [savingTags, setSavingTags] = useState(false);
|
||||
|
||||
// Notes tab
|
||||
const [notes, setNotes] = useState<ContactNote[]>([]);
|
||||
const [newNote, setNewNote] = useState('');
|
||||
const [savingNote, setSavingNote] = useState(false);
|
||||
const [loadingNotes, setLoadingNotes] = useState(false);
|
||||
|
||||
// Custom fields tab
|
||||
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||
const [customValues, setCustomValues] = useState<Record<string, string>>({});
|
||||
const [savingCustom, setSavingCustom] = useState(false);
|
||||
const [loadingCustom, setLoadingCustom] = useState(false);
|
||||
|
||||
const fetchContact = useCallback(async () => {
|
||||
if (!contactId) return;
|
||||
setLoading(true);
|
||||
|
||||
const { data } = await supabase
|
||||
.from('contacts')
|
||||
.select('*')
|
||||
.eq('id', contactId)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setContact(data);
|
||||
setEditName(data.name ?? '');
|
||||
setEditPhone(data.phone);
|
||||
setEditEmail(data.email ?? '');
|
||||
setEditCompany(data.company ?? '');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [contactId, supabase]);
|
||||
|
||||
const fetchTags = useCallback(async () => {
|
||||
if (!contactId) return;
|
||||
|
||||
const [tagsRes, contactTagsRes] = await Promise.all([
|
||||
supabase.from('tags').select('*').order('name'),
|
||||
supabase.from('contact_tags').select('tag_id').eq('contact_id', contactId),
|
||||
]);
|
||||
|
||||
if (tagsRes.data) setAllTags(tagsRes.data);
|
||||
if (contactTagsRes.data) {
|
||||
setContactTagIds(contactTagsRes.data.map((ct) => ct.tag_id));
|
||||
}
|
||||
}, [contactId, supabase]);
|
||||
|
||||
const fetchNotes = useCallback(async () => {
|
||||
if (!contactId) return;
|
||||
setLoadingNotes(true);
|
||||
|
||||
const { data } = await supabase
|
||||
.from('contact_notes')
|
||||
.select('*')
|
||||
.eq('contact_id', contactId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (data) setNotes(data);
|
||||
setLoadingNotes(false);
|
||||
}, [contactId, supabase]);
|
||||
|
||||
const fetchCustomFields = useCallback(async () => {
|
||||
if (!contactId) return;
|
||||
setLoadingCustom(true);
|
||||
|
||||
const [fieldsRes, valuesRes] = await Promise.all([
|
||||
supabase.from('custom_fields').select('*').order('field_name'),
|
||||
supabase
|
||||
.from('contact_custom_values')
|
||||
.select('*')
|
||||
.eq('contact_id', contactId),
|
||||
]);
|
||||
|
||||
if (fieldsRes.data) setCustomFields(fieldsRes.data);
|
||||
if (valuesRes.data) {
|
||||
const map: Record<string, string> = {};
|
||||
valuesRes.data.forEach((v) => {
|
||||
map[v.custom_field_id] = v.value ?? '';
|
||||
});
|
||||
setCustomValues(map);
|
||||
}
|
||||
setLoadingCustom(false);
|
||||
}, [contactId, supabase]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && contactId) {
|
||||
fetchContact();
|
||||
fetchTags();
|
||||
fetchNotes();
|
||||
fetchCustomFields();
|
||||
}
|
||||
}, [open, contactId, fetchContact, fetchTags, fetchNotes, fetchCustomFields]);
|
||||
|
||||
async function copyPhone() {
|
||||
if (!contact) return;
|
||||
await navigator.clipboard.writeText(contact.phone);
|
||||
setCopiedPhone(true);
|
||||
setTimeout(() => setCopiedPhone(false), 2000);
|
||||
}
|
||||
|
||||
async function saveDetails() {
|
||||
if (!contactId || !editPhone.trim()) {
|
||||
toast.error('Phone number is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setSavingDetails(true);
|
||||
const { error } = await supabase
|
||||
.from('contacts')
|
||||
.update({
|
||||
name: editName.trim() || null,
|
||||
phone: editPhone.trim(),
|
||||
email: editEmail.trim() || null,
|
||||
company: editCompany.trim() || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', contactId);
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to update contact');
|
||||
} else {
|
||||
toast.success('Contact updated');
|
||||
fetchContact();
|
||||
onUpdated();
|
||||
}
|
||||
setSavingDetails(false);
|
||||
}
|
||||
|
||||
async function toggleTag(tagId: string) {
|
||||
if (!contactId) return;
|
||||
setSavingTags(true);
|
||||
|
||||
const isSelected = contactTagIds.includes(tagId);
|
||||
|
||||
if (isSelected) {
|
||||
const { error } = await supabase
|
||||
.from('contact_tags')
|
||||
.delete()
|
||||
.eq('contact_id', contactId)
|
||||
.eq('tag_id', tagId);
|
||||
if (!error) {
|
||||
setContactTagIds((prev) => prev.filter((id) => id !== tagId));
|
||||
onUpdated();
|
||||
}
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('contact_tags')
|
||||
.insert({ contact_id: contactId, tag_id: tagId });
|
||||
if (!error) {
|
||||
setContactTagIds((prev) => [...prev, tagId]);
|
||||
onUpdated();
|
||||
}
|
||||
}
|
||||
setSavingTags(false);
|
||||
}
|
||||
|
||||
async function addNote() {
|
||||
if (!contactId || !newNote.trim()) return;
|
||||
setSavingNote(true);
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
toast.error('Not authenticated');
|
||||
setSavingNote(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase.from('contact_notes').insert({
|
||||
contact_id: contactId,
|
||||
user_id: user.id,
|
||||
note_text: newNote.trim(),
|
||||
});
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to add note');
|
||||
} else {
|
||||
setNewNote('');
|
||||
fetchNotes();
|
||||
toast.success('Note added');
|
||||
}
|
||||
setSavingNote(false);
|
||||
}
|
||||
|
||||
async function deleteNote(noteId: string) {
|
||||
const { error } = await supabase
|
||||
.from('contact_notes')
|
||||
.delete()
|
||||
.eq('id', noteId);
|
||||
|
||||
if (error) {
|
||||
toast.error('Failed to delete note');
|
||||
} else {
|
||||
setNotes((prev) => prev.filter((n) => n.id !== noteId));
|
||||
toast.success('Note deleted');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCustomFields() {
|
||||
if (!contactId) return;
|
||||
setSavingCustom(true);
|
||||
|
||||
try {
|
||||
// Delete existing values and re-insert
|
||||
await supabase
|
||||
.from('contact_custom_values')
|
||||
.delete()
|
||||
.eq('contact_id', contactId);
|
||||
|
||||
const rows = Object.entries(customValues)
|
||||
.filter(([, val]) => val.trim())
|
||||
.map(([fieldId, val]) => ({
|
||||
contact_id: contactId,
|
||||
custom_field_id: fieldId,
|
||||
value: val.trim(),
|
||||
}));
|
||||
|
||||
if (rows.length > 0) {
|
||||
const { error } = await supabase
|
||||
.from('contact_custom_values')
|
||||
.insert(rows);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
toast.success('Custom fields saved');
|
||||
} catch {
|
||||
toast.error('Failed to save custom fields');
|
||||
}
|
||||
setSavingCustom(false);
|
||||
}
|
||||
|
||||
function getInitials(name?: string | null) {
|
||||
if (!name) return '?';
|
||||
return name
|
||||
.split(' ')
|
||||
.map((w) => w[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
{loading || !contact ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="size-6 animate-spin text-emerald-500" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<SheetHeader className="p-4 border-b border-slate-700/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="size-12 bg-slate-800 border border-slate-700">
|
||||
<AvatarFallback className="bg-emerald-500/10 text-emerald-400 text-sm font-medium">
|
||||
{getInitials(contact.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<SheetTitle className="text-white truncate">
|
||||
{contact.name || 'Unknown'}
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-slate-400 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">
|
||||
<button
|
||||
onClick={copyPhone}
|
||||
className="flex items-center gap-1 hover:text-emerald-400 transition-colors cursor-pointer"
|
||||
>
|
||||
<Phone className="size-3" />
|
||||
{contact.phone}
|
||||
{copiedPhone ? (
|
||||
<Check className="size-3 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
{contact.email && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Mail className="size-3" />
|
||||
{contact.email}
|
||||
</span>
|
||||
)}
|
||||
{contact.company && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Building2 className="size-3" />
|
||||
{contact.company}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
<TabsTrigger
|
||||
value="details"
|
||||
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||
>
|
||||
Details
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="tags"
|
||||
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||
>
|
||||
Tags
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="notes"
|
||||
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||
>
|
||||
Notes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="custom"
|
||||
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||
>
|
||||
Custom Fields
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Details Tab */}
|
||||
<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>
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-400 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-400 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-slate-400 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"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={saveDetails}
|
||||
disabled={savingDetails}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white w-full"
|
||||
size="sm"
|
||||
>
|
||||
{savingDetails ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-3.5" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* 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">
|
||||
Click a tag to add or remove it from this contact.
|
||||
</p>
|
||||
{allTags.length === 0 ? (
|
||||
<p className="text-sm text-slate-500">
|
||||
No tags available. Create tags in Settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => {
|
||||
const selected = contactTagIds.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
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-emerald-500 ring-offset-1 ring-offset-slate-900'
|
||||
: 'opacity-50 hover:opacity-80'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: tag.color + '20',
|
||||
color: tag.color,
|
||||
}}
|
||||
>
|
||||
{selected && <Check className="size-3 mr-1" />}
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Notes Tab */}
|
||||
<TabsContent value="notes" className="flex-1 flex flex-col min-h-0 px-4 py-3">
|
||||
<div className="space-y-2 mb-3">
|
||||
<Textarea
|
||||
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"
|
||||
/>
|
||||
<Button
|
||||
onClick={addNote}
|
||||
disabled={!newNote.trim() || savingNote}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
size="sm"
|
||||
>
|
||||
{savingNote ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Plus className="size-3.5" />
|
||||
)}
|
||||
Add Note
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</div>
|
||||
) : notes.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 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"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm text-slate-300 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"
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mt-1.5">
|
||||
{new Date(note.created_at).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Custom Fields Tab */}
|
||||
<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" />
|
||||
</div>
|
||||
) : customFields.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 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">
|
||||
{field.field_name}
|
||||
</Label>
|
||||
<Input
|
||||
value={customValues[field.id] ?? ''}
|
||||
onChange={(e) =>
|
||||
setCustomValues((prev) => ({
|
||||
...prev,
|
||||
[field.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={`Enter ${field.field_name}...`}
|
||||
className="bg-slate-800 border-slate-700 text-white h-8 text-sm placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={saveCustomFields}
|
||||
disabled={savingCustom}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white w-full"
|
||||
size="sm"
|
||||
>
|
||||
{savingCustom ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Save className="size-3.5" />
|
||||
)}
|
||||
Save Custom Fields
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
286
src/components/contacts/contact-form.tsx
Normal file
286
src/components/contacts/contact-form.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import type { Contact, Tag, ContactTag } from '@/types';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ContactFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
contact?: Contact | null;
|
||||
contactTags?: ContactTag[];
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function ContactForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
contact,
|
||||
contactTags = [],
|
||||
onSaved,
|
||||
}: ContactFormProps) {
|
||||
const supabase = createClient();
|
||||
const isEdit = !!contact;
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [company, setCompany] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||
const [loadingTags, setLoadingTags] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(contact?.name ?? '');
|
||||
setPhone(contact?.phone ?? '');
|
||||
setEmail(contact?.email ?? '');
|
||||
setCompany(contact?.company ?? '');
|
||||
setSelectedTagIds(contactTags.map((ct) => ct.tag_id));
|
||||
fetchTags();
|
||||
}
|
||||
}, [open, contact]);
|
||||
|
||||
async function fetchTags() {
|
||||
setLoadingTags(true);
|
||||
const { data } = await supabase
|
||||
.from('tags')
|
||||
.select('*')
|
||||
.order('name');
|
||||
if (data) setTags(data);
|
||||
setLoadingTags(false);
|
||||
}
|
||||
|
||||
function toggleTag(tagId: string) {
|
||||
setSelectedTagIds((prev) =>
|
||||
prev.includes(tagId)
|
||||
? prev.filter((id) => id !== tagId)
|
||||
: [...prev, tagId]
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!phone.trim()) {
|
||||
toast.error('Phone number is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
let contactId = contact?.id;
|
||||
|
||||
if (isEdit && contactId) {
|
||||
const { error } = await supabase
|
||||
.from('contacts')
|
||||
.update({
|
||||
name: name.trim() || null,
|
||||
phone: phone.trim(),
|
||||
email: email.trim() || null,
|
||||
company: company.trim() || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', contactId);
|
||||
if (error) throw error;
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('contacts')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: name.trim() || null,
|
||||
phone: phone.trim(),
|
||||
email: email.trim() || null,
|
||||
company: company.trim() || null,
|
||||
})
|
||||
.select('id')
|
||||
.single();
|
||||
if (error) throw error;
|
||||
contactId = data.id;
|
||||
}
|
||||
|
||||
// Sync tags
|
||||
if (contactId) {
|
||||
await supabase
|
||||
.from('contact_tags')
|
||||
.delete()
|
||||
.eq('contact_id', contactId);
|
||||
|
||||
if (selectedTagIds.length > 0) {
|
||||
const tagRows = selectedTagIds.map((tag_id) => ({
|
||||
contact_id: contactId!,
|
||||
tag_id,
|
||||
}));
|
||||
const { error: tagError } = await supabase
|
||||
.from('contact_tags')
|
||||
.insert(tagRows);
|
||||
if (tagError) throw tagError;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(isEdit ? 'Contact updated' : 'Contact created');
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to save contact';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
{isEdit ? 'Edit Contact' : 'Add Contact'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
{isEdit
|
||||
? 'Update the contact details below.'
|
||||
: 'Fill in the details to create a new contact.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cf-name" className="text-slate-300">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="cf-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cf-phone" className="text-slate-300">
|
||||
Phone <span className="text-red-400">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="cf-phone"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
placeholder="+1 234 567 8900"
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
Include country code, e.g. +1 for US
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cf-email" className="text-slate-300">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="cf-email"
|
||||
type="email"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cf-company" className="text-slate-300">
|
||||
Company
|
||||
</Label>
|
||||
<Input
|
||||
id="cf-company"
|
||||
value={company}
|
||||
onChange={(e) => setCompany(e.target.value)}
|
||||
placeholder="Acme Inc."
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Tags</Label>
|
||||
{loadingTags ? (
|
||||
<div className="flex items-center gap-2 text-slate-500 text-sm">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
Loading tags...
|
||||
</div>
|
||||
) : tags.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
No tags available. Create tags in Settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{tags.map((tag) => {
|
||||
const selected = selectedTagIds.includes(tag.id);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
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-emerald-500 ring-offset-1 ring-offset-slate-900'
|
||||
: 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: tag.color + '20',
|
||||
color: tag.color,
|
||||
borderColor: tag.color,
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||
{isEdit ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
311
src/components/contacts/import-modal.tsx
Normal file
311
src/components/contacts/import-modal.tsx
Normal file
@ -0,0 +1,311 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Upload, FileText, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
interface ImportModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
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) {
|
||||
const supabase = createClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [result, setResult] = useState<{ imported: number; failed: number } | null>(null);
|
||||
|
||||
function reset() {
|
||||
setFile(null);
|
||||
setParsedRows([]);
|
||||
setResult(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (!open) reset();
|
||||
onOpenChange(open);
|
||||
}
|
||||
|
||||
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const selected = e.target.files?.[0];
|
||||
if (!selected) return;
|
||||
|
||||
setFile(selected);
|
||||
setResult(null);
|
||||
|
||||
const text = await selected.text();
|
||||
const rows = parseCSV(text);
|
||||
|
||||
if (rows.length === 0) {
|
||||
toast.error('No valid rows found. Ensure CSV has a "phone" column header.');
|
||||
setParsedRows([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setParsedRows(rows);
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
if (parsedRows.length === 0) return;
|
||||
setImporting(true);
|
||||
|
||||
try {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) throw new Error('Not authenticated');
|
||||
|
||||
let imported = 0;
|
||||
let failed = 0;
|
||||
|
||||
// Batch insert in chunks of 50
|
||||
const chunkSize = 50;
|
||||
for (let i = 0; i < parsedRows.length; i += chunkSize) {
|
||||
const chunk = parsedRows.slice(i, i + chunkSize);
|
||||
const rows = chunk.map((row) => ({
|
||||
user_id: user.id,
|
||||
phone: row.phone,
|
||||
name: row.name || null,
|
||||
email: row.email || null,
|
||||
company: row.company || null,
|
||||
}));
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('contacts')
|
||||
.insert(rows)
|
||||
.select('id');
|
||||
|
||||
if (error) {
|
||||
// Try individual inserts for this chunk
|
||||
for (const row of rows) {
|
||||
const { error: singleErr } = await supabase.from('contacts').insert(row);
|
||||
if (singleErr) {
|
||||
failed++;
|
||||
} else {
|
||||
imported++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
imported += data?.length ?? chunk.length;
|
||||
}
|
||||
}
|
||||
|
||||
setResult({ imported, failed });
|
||||
if (imported > 0) {
|
||||
toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`);
|
||||
onImported();
|
||||
}
|
||||
if (failed > 0) {
|
||||
toast.error(`${failed} contact${failed !== 1 ? 's' : ''} failed to import`);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Import failed';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const preview = parsedRows.slice(0, 5);
|
||||
|
||||
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.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Upload area */}
|
||||
<div
|
||||
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-emerald-500/50 transition-colors"
|
||||
>
|
||||
{file ? (
|
||||
<>
|
||||
<FileText className="size-8 text-emerald-400" />
|
||||
<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
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="size-8 text-slate-500" />
|
||||
<p className="text-sm text-slate-400">
|
||||
Click to upload CSV file
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
CSV with "phone" column required
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* Preview table */}
|
||||
{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)
|
||||
</p>
|
||||
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||
<table className="w-full 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>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{parsedRows.length > 5 && (
|
||||
<p className="text-xs text-slate-500">
|
||||
...and {parsedRows.length - 5} more rows
|
||||
</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 items-center gap-4">
|
||||
{result.imported > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-emerald-400 text-sm">
|
||||
<CheckCircle className="size-4" />
|
||||
{result.imported} imported
|
||||
</div>
|
||||
)}
|
||||
{result.failed > 0 && (
|
||||
<div className="flex items-center gap-1.5 text-red-400 text-sm">
|
||||
<XCircle className="size-4" />
|
||||
{result.failed} failed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
{result ? 'Close' : 'Cancel'}
|
||||
</Button>
|
||||
{!result && (
|
||||
<Button
|
||||
type="button"
|
||||
disabled={parsedRows.length === 0 || importing}
|
||||
onClick={handleImport}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{importing && <Loader2 className="size-4 animate-spin" />}
|
||||
Import {parsedRows.length > 0 ? `${parsedRows.length} Contacts` : ''}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
288
src/components/inbox/contact-sidebar.tsx
Normal file
288
src/components/inbox/contact-sidebar.tsx
Normal file
@ -0,0 +1,288 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Contact, Deal, ContactNote, Tag } from "@/types";
|
||||
import {
|
||||
Phone,
|
||||
Mail,
|
||||
Copy,
|
||||
Check,
|
||||
User,
|
||||
Tag as TagIcon,
|
||||
DollarSign,
|
||||
StickyNote,
|
||||
Plus,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface ContactSidebarProps {
|
||||
contact: Contact | null;
|
||||
}
|
||||
|
||||
export function ContactSidebar({ contact }: ContactSidebarProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [deals, setDeals] = useState<Deal[]>([]);
|
||||
const [notes, setNotes] = useState<ContactNote[]>([]);
|
||||
const [tags, setTags] = useState<(Tag & { contact_tag_id: string })[]>([]);
|
||||
const [newNote, setNewNote] = useState("");
|
||||
const [addingNote, setAddingNote] = useState(false);
|
||||
|
||||
const fetchContactData = useCallback(async () => {
|
||||
if (!contact) return;
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
// Fetch deals, notes, and tags in parallel
|
||||
const [dealsRes, notesRes, tagsRes] = await Promise.all([
|
||||
supabase
|
||||
.from("deals")
|
||||
.select("*, stage:pipeline_stages(*)")
|
||||
.eq("contact_id", contact.id)
|
||||
.order("created_at", { ascending: false }),
|
||||
supabase
|
||||
.from("contact_notes")
|
||||
.select("*")
|
||||
.eq("contact_id", contact.id)
|
||||
.order("created_at", { ascending: false }),
|
||||
supabase
|
||||
.from("contact_tags")
|
||||
.select("id, tag_id, tags(*)")
|
||||
.eq("contact_id", contact.id),
|
||||
]);
|
||||
|
||||
if (dealsRes.data) setDeals(dealsRes.data);
|
||||
if (notesRes.data) setNotes(notesRes.data);
|
||||
if (tagsRes.data) {
|
||||
const mapped = tagsRes.data
|
||||
.filter((ct: Record<string, unknown>) => ct.tags)
|
||||
.map((ct: Record<string, unknown>) => ({
|
||||
...(ct.tags as Tag),
|
||||
contact_tag_id: ct.id as string,
|
||||
}));
|
||||
setTags(mapped);
|
||||
}
|
||||
}, [contact]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContactData();
|
||||
}, [fetchContactData]);
|
||||
|
||||
const handleCopyPhone = useCallback(async () => {
|
||||
if (!contact?.phone) return;
|
||||
await navigator.clipboard.writeText(contact.phone);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}, [contact?.phone]);
|
||||
|
||||
const handleAddNote = useCallback(async () => {
|
||||
if (!contact || !newNote.trim()) return;
|
||||
setAddingNote(true);
|
||||
|
||||
const supabase = createClient();
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("contact_notes")
|
||||
.insert({
|
||||
contact_id: contact.id,
|
||||
user_id: user?.id,
|
||||
note_text: newNote.trim(),
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (!error && data) {
|
||||
setNotes((prev) => [data, ...prev]);
|
||||
setNewNote("");
|
||||
}
|
||||
setAddingNote(false);
|
||||
}, [contact, newNote]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = contact.name || contact.phone;
|
||||
const initials = displayName.charAt(0).toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-70 flex-col border-l border-slate-800 bg-slate-900">
|
||||
<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">
|
||||
{contact.avatar_url ? (
|
||||
<img
|
||||
src={contact.avatar_url}
|
||||
alt={displayName}
|
||||
className="h-16 w-16 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
<h3 className="mt-3 text-sm font-semibold text-white">
|
||||
{displayName}
|
||||
</h3>
|
||||
{contact.company && (
|
||||
<p className="text-xs text-slate-400">{contact.company}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<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"
|
||||
>
|
||||
<Phone className="h-4 w-4 text-slate-500" />
|
||||
<span className="flex-1 text-left">{contact.phone}</span>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3 text-slate-600" />
|
||||
)}
|
||||
</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" />
|
||||
<span className="truncate">{contact.email}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-4 border-t border-slate-800" />
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||
<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>
|
||||
) : (
|
||||
tags.map((tag) => (
|
||||
<span
|
||||
key={tag.contact_tag_id}
|
||||
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||
style={{
|
||||
backgroundColor: `${tag.color}20`,
|
||||
color: tag.color,
|
||||
}}
|
||||
>
|
||||
{tag.name}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-4 border-t border-slate-800" />
|
||||
|
||||
{/* Active Deals */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||
<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>
|
||||
) : (
|
||||
deals.map((deal) => (
|
||||
<div
|
||||
key={deal.id}
|
||||
className="rounded-lg bg-slate-800 px-3 py-2"
|
||||
>
|
||||
<p className="text-sm font-medium text-white">
|
||||
{deal.title}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center justify-between text-xs text-slate-400">
|
||||
<span>
|
||||
{deal.currency ?? "$"}
|
||||
{deal.value.toLocaleString()}
|
||||
</span>
|
||||
{deal.stage && (
|
||||
<span
|
||||
className="rounded-full px-1.5 py-0.5 text-[10px]"
|
||||
style={{
|
||||
backgroundColor: `${deal.stage.color}20`,
|
||||
color: deal.stage.color,
|
||||
}}
|
||||
>
|
||||
{deal.stage.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-4 border-t border-slate-800" />
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||
<StickyNote className="h-3 w-3" />
|
||||
Notes
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<div className="flex gap-2">
|
||||
<textarea
|
||||
value={newNote}
|
||||
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-emerald-500/50"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-auto bg-emerald-600 px-2 hover:bg-emerald-500"
|
||||
onClick={handleAddNote}
|
||||
disabled={!newNote.trim() || addingNote}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-2">
|
||||
{notes.map((note) => (
|
||||
<div
|
||||
key={note.id}
|
||||
className="rounded-lg bg-slate-800 px-3 py-2"
|
||||
>
|
||||
<p className="whitespace-pre-wrap text-xs text-slate-300">
|
||||
{note.note_text}
|
||||
</p>
|
||||
<p className="mt-1 text-[10px] text-slate-600">
|
||||
{format(new Date(note.created_at), "MMM d, yyyy HH:mm")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
250
src/components/inbox/conversation-list.tsx
Normal file
250
src/components/inbox/conversation-list.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Conversation, ConversationStatus } from "@/types";
|
||||
import { Search, ChevronDown } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
|
||||
interface ConversationListProps {
|
||||
activeConversationId: string | null;
|
||||
onSelect: (conversation: Conversation) => void;
|
||||
conversations: Conversation[];
|
||||
onConversationsLoaded: (conversations: Conversation[]) => void;
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<ConversationStatus, string> = {
|
||||
open: "bg-emerald-500",
|
||||
pending: "bg-amber-500",
|
||||
closed: "bg-slate-500",
|
||||
};
|
||||
|
||||
const FILTER_OPTIONS: { label: string; value: ConversationStatus | "all" }[] = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Open", value: "open" },
|
||||
{ label: "Pending", value: "pending" },
|
||||
{ label: "Closed", value: "closed" },
|
||||
];
|
||||
|
||||
export function ConversationList({
|
||||
activeConversationId,
|
||||
onSelect,
|
||||
conversations,
|
||||
onConversationsLoaded,
|
||||
}: ConversationListProps) {
|
||||
const [search, setSearch] = useState("");
|
||||
const [filter, setFilter] = useState<ConversationStatus | "all">("all");
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchConversations = useCallback(async () => {
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase
|
||||
.from("conversations")
|
||||
.select("*, contact:contacts(*)")
|
||||
.order("last_message_at", { ascending: false });
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to fetch conversations:", error);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onConversationsLoaded(data ?? []);
|
||||
setLoading(false);
|
||||
}, [onConversationsLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConversations();
|
||||
}, [fetchConversations]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
let result = conversations;
|
||||
|
||||
if (filter !== "all") {
|
||||
result = result.filter((c) => c.status === filter);
|
||||
}
|
||||
|
||||
if (search.trim()) {
|
||||
const q = search.toLowerCase();
|
||||
result = result.filter((c) => {
|
||||
const name = c.contact?.name?.toLowerCase() ?? "";
|
||||
const phone = c.contact?.phone?.toLowerCase() ?? "";
|
||||
const lastMsg = c.last_message_text?.toLowerCase() ?? "";
|
||||
return name.includes(q) || phone.includes(q) || lastMsg.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [conversations, filter, search]);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearch(e.target.value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(conv: Conversation) => {
|
||||
onSelect(conv);
|
||||
},
|
||||
[onSelect]
|
||||
);
|
||||
|
||||
const activeFilter = FILTER_OPTIONS.find((o) => o.value === filter);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-80 flex-col border-r border-slate-800 bg-slate-900">
|
||||
{/* Search + Filter */}
|
||||
<div className="space-y-2 border-b border-slate-800 p-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
||||
<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-emerald-500/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">
|
||||
{activeFilter?.label ?? "All"}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="border-slate-700 bg-slate-800"
|
||||
>
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => setFilter(opt.value)}
|
||||
className={cn(
|
||||
"text-sm",
|
||||
filter === opt.value
|
||||
? "text-emerald-400"
|
||||
: "text-slate-300"
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Conversation Items */}
|
||||
<ScrollArea className="flex-1">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center">
|
||||
<p className="text-sm text-slate-500">No conversations found</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col">
|
||||
{filtered.map((conv) => (
|
||||
<ConversationItem
|
||||
key={conv.id}
|
||||
conversation={conv}
|
||||
isActive={conv.id === activeConversationId}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ConversationItemProps {
|
||||
conversation: Conversation;
|
||||
isActive: boolean;
|
||||
onSelect: (conversation: Conversation) => void;
|
||||
}
|
||||
|
||||
function ConversationItem({
|
||||
conversation,
|
||||
isActive,
|
||||
onSelect,
|
||||
}: ConversationItemProps) {
|
||||
const contact = conversation.contact;
|
||||
const displayName = contact?.name || contact?.phone || "Unknown";
|
||||
const initials = displayName.charAt(0).toUpperCase();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onSelect(conversation);
|
||||
}, [onSelect, conversation]);
|
||||
|
||||
const timeAgo = conversation.last_message_at
|
||||
? formatDistanceToNow(new Date(conversation.last_message_at), {
|
||||
addSuffix: false,
|
||||
})
|
||||
: "";
|
||||
|
||||
return (
|
||||
<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-emerald-500 bg-slate-800/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">
|
||||
{contact?.avatar_url ? (
|
||||
<img
|
||||
src={contact.avatar_url}
|
||||
alt={displayName}
|
||||
className="h-10 w-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
initials
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-slate-500">{timeAgo}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs text-slate-400">
|
||||
{conversation.last_message_text || "No messages yet"}
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{conversation.unread_count > 0 && (
|
||||
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-emerald-500 px-1 text-[10px] font-bold text-white">
|
||||
{conversation.unread_count}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
STATUS_COLORS[conversation.status]
|
||||
)}
|
||||
title={conversation.status}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
228
src/components/inbox/message-bubble.tsx
Normal file
228
src/components/inbox/message-bubble.tsx
Normal file
@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Message } from "@/types";
|
||||
import {
|
||||
Clock,
|
||||
Check,
|
||||
CheckCheck,
|
||||
XCircle,
|
||||
FileText,
|
||||
MapPin,
|
||||
LayoutTemplate,
|
||||
ImageOff,
|
||||
} from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: Message["status"] }) {
|
||||
switch (status) {
|
||||
case "sending":
|
||||
return <Clock className="h-3 w-3 text-slate-400" />;
|
||||
case "sent":
|
||||
return <Check className="h-3 w-3 text-slate-400" />;
|
||||
case "delivered":
|
||||
return <CheckCheck className="h-3 w-3 text-slate-400" />;
|
||||
case "read":
|
||||
return <CheckCheck className="h-3 w-3 text-blue-400" />;
|
||||
case "failed":
|
||||
return <XCircle className="h-3 w-3 text-red-400" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function MediaImage({ url, alt }: { url: string; alt: string }) {
|
||||
const [src, setSrc] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
if (!url) return;
|
||||
|
||||
// Proxy URLs need auth fetch to create blob URL
|
||||
if (url.startsWith("/api/whatsapp/media/")) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error("Failed to load media");
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
setSrc(blobUrl);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setSrc(url);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
loadImage();
|
||||
return () => {
|
||||
if (src?.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(src);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loadImage]);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-slate-700">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src ?? ""}
|
||||
alt={alt}
|
||||
className="max-h-64 max-w-60 rounded-lg object-cover"
|
||||
onError={() => setError(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageContent({ message }: { message: Message }) {
|
||||
switch (message.content_type) {
|
||||
case "text":
|
||||
return (
|
||||
<p className="whitespace-pre-wrap break-words text-sm">
|
||||
{message.content_text}
|
||||
</p>
|
||||
);
|
||||
|
||||
case "image":
|
||||
return (
|
||||
<div>
|
||||
{message.media_url && (
|
||||
<MediaImage url={message.media_url} alt="Shared image" />
|
||||
)}
|
||||
{message.content_text && (
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
|
||||
{message.content_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "video":
|
||||
return (
|
||||
<div>
|
||||
{message.media_url && (
|
||||
<video
|
||||
src={message.media_url}
|
||||
controls
|
||||
className="max-h-64 max-w-60 rounded-lg"
|
||||
/>
|
||||
)}
|
||||
{message.content_text && (
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
|
||||
{message.content_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "audio":
|
||||
return (
|
||||
<div>
|
||||
{message.media_url && (
|
||||
<audio src={message.media_url} controls className="max-w-60" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "document":
|
||||
return (
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<FileText className="h-5 w-5 shrink-0 text-slate-400" />
|
||||
<span className="truncate">
|
||||
{message.content_text || "Document"}
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
|
||||
case "template":
|
||||
return (
|
||||
<div>
|
||||
<span className="mb-1 inline-flex items-center gap-1 rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||
<LayoutTemplate className="h-3 w-3" />
|
||||
Template
|
||||
</span>
|
||||
{message.content_text && (
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
|
||||
{message.content_text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case "location":
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="h-4 w-4 shrink-0 text-slate-400" />
|
||||
<span>{message.content_text || "Location shared"}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<p className="whitespace-pre-wrap break-words text-sm">
|
||||
{message.content_text || "[Unsupported message type]"}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
const isAgent = message.sender_type === "agent" || message.sender_type === "bot";
|
||||
const time = format(new Date(message.created_at), "HH:mm");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full", isAgent ? "justify-end" : "justify-start")}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative max-w-[75%] rounded-2xl px-3 py-2",
|
||||
isAgent
|
||||
? "rounded-br-md bg-emerald-600 text-white"
|
||||
: "rounded-bl-md bg-slate-800 text-slate-100"
|
||||
)}
|
||||
>
|
||||
<MessageContent message={message} />
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 flex items-center gap-1",
|
||||
isAgent ? "justify-end" : "justify-start"
|
||||
)}
|
||||
>
|
||||
<span className="text-[10px] text-white/60">{time}</span>
|
||||
{isAgent && <StatusIcon status={message.status} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
src/components/inbox/message-composer.tsx
Normal file
131
src/components/inbox/message-composer.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useCallback, KeyboardEvent } from "react";
|
||||
import { Send, LayoutTemplate } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MessageComposerProps {
|
||||
conversationId: string;
|
||||
sessionExpired: boolean;
|
||||
onSend: (text: string) => void;
|
||||
onOpenTemplates: () => void;
|
||||
}
|
||||
|
||||
export function MessageComposer({
|
||||
conversationId,
|
||||
sessionExpired,
|
||||
onSend,
|
||||
onOpenTemplates,
|
||||
}: MessageComposerProps) {
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const adjustHeight = useCallback(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = "auto";
|
||||
// Max 4 lines (~96px)
|
||||
el.style.height = `${Math.min(el.scrollHeight, 96)}px`;
|
||||
}, []);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || sending || sessionExpired) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
onSend(trimmed);
|
||||
setText("");
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = "auto";
|
||||
}
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [text, sending, sessionExpired, onSend]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
},
|
||||
[handleSend]
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setText(e.target.value);
|
||||
adjustHeight();
|
||||
},
|
||||
[adjustHeight]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-800 bg-slate-900 p-3">
|
||||
{sessionExpired && (
|
||||
<div className="mb-2 flex items-center justify-between rounded-lg bg-amber-500/10 px-3 py-2">
|
||||
<p className="text-xs text-amber-400">
|
||||
24-hour session expired. Use a template to re-engage.
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-amber-400 hover:text-amber-300"
|
||||
onClick={onOpenTemplates}
|
||||
>
|
||||
<LayoutTemplate className="mr-1 h-3 w-3" />
|
||||
Templates
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-9 w-9 shrink-0 p-0 text-slate-400 hover:text-white"
|
||||
onClick={onOpenTemplates}
|
||||
title="Send template"
|
||||
>
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="relative flex-1">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={
|
||||
sessionExpired
|
||||
? "Session expired - use a template"
|
||||
: "Type a message... (Shift+Enter for new line)"
|
||||
}
|
||||
disabled={sessionExpired}
|
||||
rows={1}
|
||||
className={cn(
|
||||
"w-full 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-emerald-500/50",
|
||||
sessionExpired && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
/>
|
||||
<p className="mt-1 text-[10px] text-slate-600">
|
||||
Type '/' for quick replies
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-9 w-9 shrink-0 bg-emerald-600 p-0 hover:bg-emerald-500 disabled:opacity-40"
|
||||
disabled={!text.trim() || sessionExpired || sending}
|
||||
onClick={handleSend}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
330
src/components/inbox/message-thread.tsx
Normal file
330
src/components/inbox/message-thread.tsx
Normal file
@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
|
||||
import {
|
||||
MessageSquare,
|
||||
ChevronDown,
|
||||
UserPlus,
|
||||
Clock,
|
||||
} from "lucide-react";
|
||||
import { format, isToday, isYesterday, differenceInHours } from "date-fns";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { MessageBubble } from "./message-bubble";
|
||||
import { MessageComposer } from "./message-composer";
|
||||
|
||||
interface MessageThreadProps {
|
||||
conversation: Conversation | null;
|
||||
contact: Contact | null;
|
||||
messages: Message[];
|
||||
onMessagesLoaded: (messages: Message[]) => void;
|
||||
onNewMessage: (message: Message) => void;
|
||||
onStatusChange: (conversationId: string, status: ConversationStatus) => void;
|
||||
}
|
||||
|
||||
function formatDateSeparator(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
if (isToday(date)) return "Today";
|
||||
if (isYesterday(date)) return "Yesterday";
|
||||
return format(date, "MMMM d, yyyy");
|
||||
}
|
||||
|
||||
function groupMessagesByDate(messages: Message[]) {
|
||||
const groups: { date: string; messages: Message[] }[] = [];
|
||||
let currentDate = "";
|
||||
|
||||
for (const msg of messages) {
|
||||
const day = format(new Date(msg.created_at), "yyyy-MM-dd");
|
||||
if (day !== currentDate) {
|
||||
currentDate = day;
|
||||
groups.push({ date: msg.created_at, messages: [msg] });
|
||||
} else {
|
||||
groups[groups.length - 1].messages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS: { label: string; value: ConversationStatus; color: string }[] = [
|
||||
{ label: "Open", value: "open", color: "text-emerald-400" },
|
||||
{ label: "Pending", value: "pending", color: "text-amber-400" },
|
||||
{ label: "Closed", value: "closed", color: "text-slate-400" },
|
||||
];
|
||||
|
||||
export function MessageThread({
|
||||
conversation,
|
||||
contact,
|
||||
messages,
|
||||
onMessagesLoaded,
|
||||
onNewMessage,
|
||||
onStatusChange,
|
||||
}: MessageThreadProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||
|
||||
// 24-hour session timer
|
||||
const sessionInfo = useMemo(() => {
|
||||
if (!messages.length) return { expired: false, remaining: "" };
|
||||
|
||||
// Find last customer message
|
||||
const lastCustomerMsg = [...messages]
|
||||
.reverse()
|
||||
.find((m) => m.sender_type === "customer");
|
||||
|
||||
if (!lastCustomerMsg) return { expired: true, remaining: "No customer messages" };
|
||||
|
||||
const hoursSince = differenceInHours(new Date(), new Date(lastCustomerMsg.created_at));
|
||||
const expired = hoursSince >= 24;
|
||||
|
||||
if (expired) {
|
||||
return { expired: true, remaining: "Expired" };
|
||||
}
|
||||
|
||||
const hoursLeft = 24 - hoursSince;
|
||||
const remaining =
|
||||
hoursLeft >= 1
|
||||
? `${Math.floor(hoursLeft)}h remaining`
|
||||
: `${Math.floor(hoursLeft * 60)}m remaining`;
|
||||
|
||||
return { expired, remaining };
|
||||
}, [messages]);
|
||||
|
||||
const fetchMessages = useCallback(async () => {
|
||||
if (!conversation) return;
|
||||
setLoading(true);
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("messages")
|
||||
.select("*")
|
||||
.eq("conversation_id", conversation.id)
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
console.error("Failed to fetch messages:", error);
|
||||
} else {
|
||||
onMessagesLoaded(data ?? []);
|
||||
}
|
||||
|
||||
// Reset unread count
|
||||
await supabase
|
||||
.from("conversations")
|
||||
.update({ unread_count: 0 })
|
||||
.eq("id", conversation.id);
|
||||
|
||||
setLoading(false);
|
||||
}, [conversation, onMessagesLoaded]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
}, [fetchMessages]);
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
const el = scrollRef.current;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (text: string) => {
|
||||
if (!conversation) return;
|
||||
|
||||
// Optimistic update
|
||||
const optimisticMsg: Message = {
|
||||
id: `temp-${Date.now()}`,
|
||||
conversation_id: conversation.id,
|
||||
sender_type: "agent",
|
||||
content_type: "text",
|
||||
content_text: text,
|
||||
status: "sending",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
onNewMessage(optimisticMsg);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/whatsapp/send", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
conversation_id: conversation.id,
|
||||
message_type: "text",
|
||||
content_text: text,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error("Failed to send message");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to send message:", err);
|
||||
}
|
||||
},
|
||||
[conversation, onNewMessage]
|
||||
);
|
||||
|
||||
const handleStatusChange = useCallback(
|
||||
async (status: ConversationStatus) => {
|
||||
if (!conversation) return;
|
||||
|
||||
const supabase = createClient();
|
||||
await supabase
|
||||
.from("conversations")
|
||||
.update({ status })
|
||||
.eq("id", conversation.id);
|
||||
|
||||
onStatusChange(conversation.id, status);
|
||||
},
|
||||
[conversation, onStatusChange]
|
||||
);
|
||||
|
||||
const handleOpenTemplates = useCallback(() => {
|
||||
setTemplateModalOpen(true);
|
||||
// Template modal implementation would go here
|
||||
}, []);
|
||||
|
||||
// Empty state
|
||||
if (!conversation || !contact) {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center bg-slate-950">
|
||||
<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>
|
||||
<h3 className="mt-4 text-sm font-medium text-slate-400">
|
||||
Select a conversation
|
||||
</h3>
|
||||
<p className="mt-1 text-xs text-slate-600">
|
||||
Choose a conversation from the left to start messaging
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = contact.name || contact.phone;
|
||||
const messageGroups = groupMessagesByDate(messages);
|
||||
const currentStatus = STATUS_OPTIONS.find(
|
||||
(s) => s.value === conversation.status
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col bg-slate-950">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-white">{displayName}</h2>
|
||||
<p className="text-xs text-slate-400">{contact.phone}</p>
|
||||
</div>
|
||||
{/* Session timer badge */}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"ml-2 gap-1 border-slate-700 text-[10px]",
|
||||
sessionInfo.expired ? "text-red-400" : "text-emerald-400"
|
||||
)}
|
||||
>
|
||||
<Clock className="h-3 w-3" />
|
||||
{sessionInfo.remaining}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 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"
|
||||
)}>
|
||||
{currentStatus?.label ?? "Status"}
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="border-slate-700 bg-slate-800"
|
||||
>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<DropdownMenuItem
|
||||
key={opt.value}
|
||||
onClick={() => handleStatusChange(opt.value)}
|
||||
className={cn("text-sm", opt.color)}
|
||||
>
|
||||
{opt.label}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Assign button */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 gap-1 text-xs text-slate-400 hover:text-white"
|
||||
>
|
||||
<UserPlus className="h-3 w-3" />
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Area */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
</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">
|
||||
Send a template to start the conversation
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{messageGroups.map((group) => (
|
||||
<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">
|
||||
{formatDateSeparator(group.date)}
|
||||
</span>
|
||||
</div>
|
||||
{/* Messages */}
|
||||
<div className="space-y-2">
|
||||
{group.messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<MessageComposer
|
||||
conversationId={conversation.id}
|
||||
sessionExpired={sessionInfo.expired}
|
||||
onSend={handleSend}
|
||||
onOpenTemplates={handleOpenTemplates}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/components/layout/header.tsx
Normal file
56
src/components/layout/header.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { LogOut } from "lucide-react";
|
||||
|
||||
const pageTitles: Record<string, string> = {
|
||||
"/dashboard": "Dashboard",
|
||||
"/dashboard/inbox": "Inbox",
|
||||
"/dashboard/contacts": "Contacts",
|
||||
"/dashboard/pipelines": "Pipelines",
|
||||
"/dashboard/broadcasts": "Broadcasts",
|
||||
"/dashboard/automations": "Automations",
|
||||
"/dashboard/settings": "Settings",
|
||||
};
|
||||
|
||||
function getPageTitle(pathname: string): string {
|
||||
if (pageTitles[pathname]) return pageTitles[pathname];
|
||||
|
||||
const match = Object.entries(pageTitles).find(([path]) =>
|
||||
pathname.startsWith(path)
|
||||
);
|
||||
return match ? match[1] : "Dashboard";
|
||||
}
|
||||
|
||||
export function Header() {
|
||||
const pathname = usePathname();
|
||||
const { profile, signOut } = useAuth();
|
||||
const title = getPageTitle(pathname);
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-800 bg-slate-950 px-6">
|
||||
<h1 className="text-lg font-semibold text-white">{title}</h1>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
||||
{profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"}
|
||||
</div>
|
||||
<div className="hidden sm:block">
|
||||
<p className="text-sm font-medium text-white">
|
||||
{profile?.full_name ?? "User"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
125
src/components/layout/sidebar.tsx
Normal file
125
src/components/layout/sidebar.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
MessageSquare,
|
||||
Users,
|
||||
GitBranch,
|
||||
Radio,
|
||||
Zap,
|
||||
Settings,
|
||||
LogOut,
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ href: "/dashboard/inbox", label: "Inbox", icon: MessageSquare },
|
||||
{ href: "/dashboard/contacts", label: "Contacts", icon: Users },
|
||||
{ href: "/dashboard/pipelines", label: "Pipelines", icon: GitBranch },
|
||||
{ href: "/dashboard/broadcasts", label: "Broadcasts", icon: Radio },
|
||||
{ href: "/dashboard/automations", label: "Automations", icon: Zap },
|
||||
];
|
||||
|
||||
const bottomNavItems = [
|
||||
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const { profile, signOut } = useAuth();
|
||||
|
||||
return (
|
||||
<aside className="flex h-screen w-60 flex-col border-r border-slate-800 bg-slate-900">
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center gap-2 border-b border-slate-800 px-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500">
|
||||
<MessageSquare className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-semibold text-white">WaCRM</span>
|
||||
</div>
|
||||
|
||||
{/* Main navigation */}
|
||||
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||
<ul className="flex flex-col gap-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
pathname === item.href ||
|
||||
(item.href !== "/dashboard" && pathname.startsWith(item.href));
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-emerald-500/10 text-emerald-500"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="my-4 border-t border-slate-800" />
|
||||
|
||||
{/* Bottom nav items */}
|
||||
<ul className="flex flex-col gap-1">
|
||||
{bottomNavItems.map((item) => {
|
||||
const isActive = pathname.startsWith(item.href);
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-emerald-500/10 text-emerald-500"
|
||||
: "text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className="border-t border-slate-800 p-3">
|
||||
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
||||
{profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
{profile?.full_name ?? "User"}
|
||||
</p>
|
||||
<p className="truncate text-xs text-slate-400">
|
||||
{profile?.email ?? ""}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={signOut}
|
||||
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
123
src/components/pipelines/deal-card.tsx
Normal file
123
src/components/pipelines/deal-card.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
"use client";
|
||||
|
||||
import { Deal } from "@/types";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Calendar,
|
||||
User,
|
||||
DollarSign,
|
||||
} from "lucide-react";
|
||||
|
||||
function formatCurrency(value: number, currency?: string) {
|
||||
const cur = currency || "USD";
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: cur,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
interface DealCardProps {
|
||||
deal: Deal;
|
||||
onEdit: (deal: Deal) => void;
|
||||
onMoveStage: (dealId: string, direction: "prev" | "next") => void;
|
||||
hasPrev: boolean;
|
||||
hasNext: boolean;
|
||||
}
|
||||
|
||||
export function DealCard({
|
||||
deal,
|
||||
onEdit,
|
||||
onMoveStage,
|
||||
hasPrev,
|
||||
hasNext,
|
||||
}: DealCardProps) {
|
||||
const statusColor =
|
||||
deal.status === "won"
|
||||
? "text-emerald-400"
|
||||
: deal.status === "lost"
|
||||
? "text-red-400"
|
||||
: "text-slate-400";
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onEdit(deal)}
|
||||
className="group cursor-pointer rounded-lg border border-slate-700/50 bg-slate-800/50 p-3 transition-all hover:border-slate-600 hover:bg-slate-800"
|
||||
>
|
||||
<div className="mb-2 flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-medium text-white leading-snug truncate">
|
||||
{deal.title}
|
||||
</h4>
|
||||
{deal.status && deal.status !== "open" && (
|
||||
<Badge variant="outline" className={statusColor}>
|
||||
{deal.status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<DollarSign className="h-3.5 w-3.5 text-emerald-400" />
|
||||
<span className="text-sm font-semibold text-emerald-400">
|
||||
{formatCurrency(deal.value, deal.currency)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{deal.contact && (
|
||||
<div className="mb-1.5 flex items-center gap-1.5">
|
||||
<User className="h-3 w-3 text-slate-500" />
|
||||
<span className="text-xs text-slate-400 truncate">
|
||||
{deal.contact.name || deal.contact.phone}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deal.expected_close_date && (
|
||||
<div className="mb-2 flex items-center gap-1.5">
|
||||
<Calendar className="h-3 w-3 text-slate-500" />
|
||||
<span className="text-xs text-slate-400">
|
||||
{formatDate(deal.expected_close_date)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={!hasPrev}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveStage(deal.id, "prev");
|
||||
}}
|
||||
className="text-slate-400 hover:text-white"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={!hasNext}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMoveStage(deal.id, "next");
|
||||
}}
|
||||
className="text-slate-400 hover:text-white"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
229
src/components/pipelines/deal-form.tsx
Normal file
229
src/components/pipelines/deal-form.tsx
Normal file
@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { Deal, Contact, PipelineStage } from "@/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
|
||||
interface DealFormProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
deal?: Deal | null;
|
||||
pipelineId: string;
|
||||
stages: PipelineStage[];
|
||||
defaultStageId?: string;
|
||||
onSaved: () => void;
|
||||
}
|
||||
|
||||
export function DealForm({
|
||||
open,
|
||||
onOpenChange,
|
||||
deal,
|
||||
pipelineId,
|
||||
stages,
|
||||
defaultStageId,
|
||||
onSaved,
|
||||
}: DealFormProps) {
|
||||
const [title, setTitle] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
const [currency, setCurrency] = useState("USD");
|
||||
const [contactId, setContactId] = useState("");
|
||||
const [stageId, setStageId] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [expectedCloseDate, setExpectedCloseDate] = useState("");
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadContacts();
|
||||
if (deal) {
|
||||
setTitle(deal.title);
|
||||
setValue(String(deal.value));
|
||||
setCurrency(deal.currency || "USD");
|
||||
setContactId(deal.contact_id);
|
||||
setStageId(deal.stage_id);
|
||||
setNotes(deal.notes || "");
|
||||
setExpectedCloseDate(deal.expected_close_date || "");
|
||||
} else {
|
||||
setTitle("");
|
||||
setValue("");
|
||||
setCurrency("USD");
|
||||
setContactId("");
|
||||
setStageId(defaultStageId || (stages[0]?.id ?? ""));
|
||||
setNotes("");
|
||||
setExpectedCloseDate("");
|
||||
}
|
||||
}
|
||||
}, [open, deal, defaultStageId, stages]);
|
||||
|
||||
async function loadContacts() {
|
||||
const { data } = await supabase
|
||||
.from("contacts")
|
||||
.select("*")
|
||||
.order("name");
|
||||
if (data) setContacts(data);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!title.trim() || !contactId || !stageId) return;
|
||||
setSaving(true);
|
||||
|
||||
const payload = {
|
||||
title: title.trim(),
|
||||
value: parseFloat(value) || 0,
|
||||
currency,
|
||||
contact_id: contactId,
|
||||
pipeline_id: pipelineId,
|
||||
stage_id: stageId,
|
||||
notes: notes.trim() || null,
|
||||
expected_close_date: expectedCloseDate || null,
|
||||
};
|
||||
|
||||
if (deal) {
|
||||
await supabase.from("deals").update(payload).eq("id", deal.id);
|
||||
} else {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
await supabase
|
||||
.from("deals")
|
||||
.insert({ ...payload, user_id: user.id });
|
||||
}
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
onOpenChange(false);
|
||||
onSaved();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
{deal ? "Edit Deal" : "New Deal"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Title</Label>
|
||||
<Input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="Deal title"
|
||||
className="bg-slate-800 border-slate-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Value</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
placeholder="0"
|
||||
className="bg-slate-800 border-slate-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Currency</Label>
|
||||
<select
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="USD">USD</option>
|
||||
<option value="EUR">EUR</option>
|
||||
<option value="GBP">GBP</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Contact</Label>
|
||||
<select
|
||||
value={contactId}
|
||||
onChange={(e) => setContactId(e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
<option value="">Select a contact</option>
|
||||
{contacts.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name || c.phone}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Stage</Label>
|
||||
<select
|
||||
value={stageId}
|
||||
onChange={(e) => setStageId(e.target.value)}
|
||||
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||
>
|
||||
{stages.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Expected Close Date</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={expectedCloseDate}
|
||||
onChange={(e) => setExpectedCloseDate(e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Notes</Label>
|
||||
<Textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add notes..."
|
||||
className="bg-slate-800 border-slate-700 text-white min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !title.trim() || !contactId}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
{saving ? "Saving..." : deal ? "Update Deal" : "Create Deal"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
200
src/components/pipelines/pipeline-board.tsx
Normal file
200
src/components/pipelines/pipeline-board.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { PipelineStage, Deal } from "@/types";
|
||||
import { DealCard } from "./deal-card";
|
||||
import { DealForm } from "./deal-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, DollarSign } from "lucide-react";
|
||||
|
||||
function formatCurrency(value: number) {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
interface PipelineBoardProps {
|
||||
pipelineId: string;
|
||||
stages: PipelineStage[];
|
||||
onStagesChange: () => void;
|
||||
}
|
||||
|
||||
export function PipelineBoard({
|
||||
pipelineId,
|
||||
stages,
|
||||
onStagesChange,
|
||||
}: PipelineBoardProps) {
|
||||
const [deals, setDeals] = useState<Deal[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dealFormOpen, setDealFormOpen] = useState(false);
|
||||
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
|
||||
const [defaultStageId, setDefaultStageId] = useState<string>("");
|
||||
|
||||
const supabase = createClient();
|
||||
const sortedStages = [...stages].sort((a, b) => a.position - b.position);
|
||||
|
||||
useEffect(() => {
|
||||
if (pipelineId) {
|
||||
loadDeals();
|
||||
}
|
||||
}, [pipelineId]);
|
||||
|
||||
async function loadDeals() {
|
||||
setLoading(true);
|
||||
const { data } = await supabase
|
||||
.from("deals")
|
||||
.select("*, contact:contacts(*)")
|
||||
.eq("pipeline_id", pipelineId)
|
||||
.order("created_at", { ascending: false });
|
||||
if (data) setDeals(data);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function getDealsForStage(stageId: string) {
|
||||
return deals.filter((d) => d.stage_id === stageId);
|
||||
}
|
||||
|
||||
function getTotalValue(stageId: string) {
|
||||
return getDealsForStage(stageId).reduce((sum, d) => sum + d.value, 0);
|
||||
}
|
||||
|
||||
function handleAddDeal(stageId: string) {
|
||||
setEditingDeal(null);
|
||||
setDefaultStageId(stageId);
|
||||
setDealFormOpen(true);
|
||||
}
|
||||
|
||||
function handleEditDeal(deal: Deal) {
|
||||
setEditingDeal(deal);
|
||||
setDefaultStageId(deal.stage_id);
|
||||
setDealFormOpen(true);
|
||||
}
|
||||
|
||||
async function handleMoveStage(dealId: string, direction: "prev" | "next") {
|
||||
const deal = deals.find((d) => d.id === dealId);
|
||||
if (!deal) return;
|
||||
|
||||
const currentIndex = sortedStages.findIndex(
|
||||
(s) => s.id === deal.stage_id
|
||||
);
|
||||
const targetIndex =
|
||||
direction === "prev" ? currentIndex - 1 : currentIndex + 1;
|
||||
if (targetIndex < 0 || targetIndex >= sortedStages.length) return;
|
||||
|
||||
const targetStage = sortedStages[targetIndex];
|
||||
await supabase
|
||||
.from("deals")
|
||||
.update({ stage_id: targetStage.id })
|
||||
.eq("id", dealId);
|
||||
|
||||
setDeals(
|
||||
deals.map((d) =>
|
||||
d.id === dealId ? { ...d, stage_id: targetStage.id } : d
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{sortedStages.map((stage) => (
|
||||
<div
|
||||
key={stage.id}
|
||||
className="flex w-72 shrink-0 flex-col rounded-xl border border-slate-800 bg-slate-900/50"
|
||||
>
|
||||
<div className="p-3 border-b border-slate-800">
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-slate-800" />
|
||||
<div className="mt-2 h-3 w-16 animate-pulse rounded bg-slate-800" />
|
||||
</div>
|
||||
<div className="p-3 space-y-3">
|
||||
{[1, 2].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-24 animate-pulse rounded-lg bg-slate-800/50"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[calc(100vh-14rem)]">
|
||||
{sortedStages.map((stage, stageIndex) => {
|
||||
const stageDeals = getDealsForStage(stage.id);
|
||||
const totalValue = getTotalValue(stage.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stage.id}
|
||||
className="flex w-72 shrink-0 flex-col rounded-xl border border-slate-800 bg-slate-900/50"
|
||||
>
|
||||
{/* Column header */}
|
||||
<div className="p-3 border-b border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="h-2.5 w-2.5 rounded-full shrink-0"
|
||||
style={{ backgroundColor: stage.color }}
|
||||
/>
|
||||
<h3 className="text-sm font-medium text-white truncate">
|
||||
{stage.name}
|
||||
</h3>
|
||||
<span className="ml-auto shrink-0 flex h-5 w-5 items-center justify-center rounded-full bg-slate-800 text-xs text-slate-400">
|
||||
{stageDeals.length}
|
||||
</span>
|
||||
</div>
|
||||
{totalValue > 0 && (
|
||||
<div className="mt-1.5 flex items-center gap-1 text-xs text-slate-400">
|
||||
<DollarSign className="h-3 w-3" />
|
||||
{formatCurrency(totalValue)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Deal cards */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{stageDeals.map((deal) => (
|
||||
<DealCard
|
||||
key={deal.id}
|
||||
deal={deal}
|
||||
onEdit={handleEditDeal}
|
||||
onMoveStage={handleMoveStage}
|
||||
hasPrev={stageIndex > 0}
|
||||
hasNext={stageIndex < sortedStages.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleAddDeal(stage.id)}
|
||||
className="w-full justify-start text-slate-400 hover:text-white border border-dashed border-slate-700 hover:border-slate-600"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Deal
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<DealForm
|
||||
open={dealFormOpen}
|
||||
onOpenChange={setDealFormOpen}
|
||||
deal={editingDeal}
|
||||
pipelineId={pipelineId}
|
||||
stages={sortedStages}
|
||||
defaultStageId={defaultStageId}
|
||||
onSaved={loadDeals}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
326
src/components/pipelines/pipeline-settings.tsx
Normal file
326
src/components/pipelines/pipeline-settings.tsx
Normal file
@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { Pipeline, PipelineStage } from "@/types";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Trash2,
|
||||
Plus,
|
||||
GripVertical,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
AlertTriangle,
|
||||
} from "lucide-react";
|
||||
|
||||
const STAGE_COLORS = [
|
||||
"#6366f1",
|
||||
"#8b5cf6",
|
||||
"#ec4899",
|
||||
"#f43f5e",
|
||||
"#f97316",
|
||||
"#eab308",
|
||||
"#22c55e",
|
||||
"#14b8a6",
|
||||
"#06b6d4",
|
||||
"#3b82f6",
|
||||
];
|
||||
|
||||
interface PipelineSettingsProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
pipeline: Pipeline;
|
||||
stages: PipelineStage[];
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
export function PipelineSettings({
|
||||
open,
|
||||
onOpenChange,
|
||||
pipeline,
|
||||
stages,
|
||||
onUpdated,
|
||||
}: PipelineSettingsProps) {
|
||||
const [name, setName] = useState(pipeline.name);
|
||||
const [localStages, setLocalStages] = useState<PipelineStage[]>(stages);
|
||||
const [newStageName, setNewStageName] = useState("");
|
||||
const [newStageColor, setNewStageColor] = useState(STAGE_COLORS[0]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(pipeline.name);
|
||||
setLocalStages([...stages].sort((a, b) => a.position - b.position));
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
}, [open, pipeline, stages]);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
|
||||
await supabase
|
||||
.from("pipelines")
|
||||
.update({ name: name.trim() })
|
||||
.eq("id", pipeline.id);
|
||||
|
||||
for (let i = 0; i < localStages.length; i++) {
|
||||
const stage = localStages[i];
|
||||
await supabase
|
||||
.from("pipeline_stages")
|
||||
.update({ name: stage.name, color: stage.color, position: i })
|
||||
.eq("id", stage.id);
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
onOpenChange(false);
|
||||
onUpdated();
|
||||
}
|
||||
|
||||
async function handleAddStage() {
|
||||
if (!newStageName.trim()) return;
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
|
||||
const { data } = await supabase
|
||||
.from("pipeline_stages")
|
||||
.insert({
|
||||
pipeline_id: pipeline.id,
|
||||
name: newStageName.trim(),
|
||||
color: newStageColor,
|
||||
position: localStages.length,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setLocalStages([...localStages, data]);
|
||||
setNewStageName("");
|
||||
setNewStageColor(
|
||||
STAGE_COLORS[(localStages.length + 1) % STAGE_COLORS.length]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemoveStage(stageId: string) {
|
||||
await supabase.from("pipeline_stages").delete().eq("id", stageId);
|
||||
setLocalStages(localStages.filter((s) => s.id !== stageId));
|
||||
}
|
||||
|
||||
function moveStage(index: number, direction: "up" | "down") {
|
||||
const newStages = [...localStages];
|
||||
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
||||
if (targetIndex < 0 || targetIndex >= newStages.length) return;
|
||||
[newStages[index], newStages[targetIndex]] = [
|
||||
newStages[targetIndex],
|
||||
newStages[index],
|
||||
];
|
||||
setLocalStages(newStages);
|
||||
}
|
||||
|
||||
async function handleDeletePipeline() {
|
||||
setDeleting(true);
|
||||
await supabase.from("deals").delete().eq("pipeline_id", pipeline.id);
|
||||
await supabase
|
||||
.from("pipeline_stages")
|
||||
.delete()
|
||||
.eq("pipeline_id", pipeline.id);
|
||||
await supabase.from("pipelines").delete().eq("id", pipeline.id);
|
||||
setDeleting(false);
|
||||
onOpenChange(false);
|
||||
onUpdated();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Pipeline Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{showDeleteConfirm ? (
|
||||
<div className="py-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400 shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-400">
|
||||
Delete Pipeline
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">
|
||||
This will permanently delete the pipeline, all stages, and all
|
||||
deals within it. This cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="border-slate-700 text-slate-300"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDeletePipeline}
|
||||
disabled={deleting}
|
||||
className="bg-red-600 text-white hover:bg-red-700"
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete Pipeline"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-4 py-2">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Pipeline Name</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-slate-300">Stages</Label>
|
||||
<div className="space-y-2">
|
||||
{localStages.map((stage, index) => (
|
||||
<div
|
||||
key={stage.id}
|
||||
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800 p-2"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-slate-500 shrink-0" />
|
||||
<div
|
||||
className="h-4 w-4 rounded-full shrink-0"
|
||||
style={{ backgroundColor: stage.color }}
|
||||
/>
|
||||
<Input
|
||||
value={stage.name}
|
||||
onChange={(e) => {
|
||||
const updated = [...localStages];
|
||||
updated[index] = {
|
||||
...updated[index],
|
||||
name: e.target.value,
|
||||
};
|
||||
setLocalStages(updated);
|
||||
}}
|
||||
className="h-7 bg-transparent border-transparent text-white text-sm focus:border-slate-600"
|
||||
/>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveStage(index, "up")}
|
||||
className="text-slate-400 hover:text-white"
|
||||
>
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
disabled={index === localStages.length - 1}
|
||||
onClick={() => moveStage(index, "down")}
|
||||
className="text-slate-400 hover:text-white"
|
||||
>
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => handleRemoveStage(stage.id)}
|
||||
className="text-slate-400 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex gap-1">
|
||||
{STAGE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
onClick={() => setNewStageColor(color)}
|
||||
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
borderColor:
|
||||
newStageColor === color
|
||||
? "white"
|
||||
: "transparent",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={newStageName}
|
||||
onChange={(e) => setNewStageName(e.target.value)}
|
||||
placeholder="New stage name"
|
||||
className="bg-slate-800 border-slate-700 text-white text-sm"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddStage();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddStage}
|
||||
disabled={!newStageName.trim()}
|
||||
className="border-slate-700 text-slate-300 shrink-0"
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="mr-auto"
|
||||
>
|
||||
Delete Pipeline
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving || !name.trim()}
|
||||
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||
>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
331
src/components/settings/tag-manager.tsx
Normal file
331
src/components/settings/tag-manager.tsx
Normal file
@ -0,0 +1,331 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, X, Loader2 } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import type { Tag } from '@/types';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
{ name: 'Red', value: '#ef4444' },
|
||||
{ name: 'Orange', value: '#f97316' },
|
||||
{ name: 'Amber', value: '#f59e0b' },
|
||||
{ name: 'Emerald', value: '#10b981' },
|
||||
{ name: 'Cyan', value: '#06b6d4' },
|
||||
{ name: 'Blue', value: '#3b82f6' },
|
||||
{ name: 'Violet', value: '#8b5cf6' },
|
||||
{ name: 'Pink', value: '#ec4899' },
|
||||
];
|
||||
|
||||
export function TagManager() {
|
||||
const supabase = createClient();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [newTagName, setNewTagName] = useState('');
|
||||
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[3].value);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTags();
|
||||
}, []);
|
||||
|
||||
async function fetchTags() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('tags')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
setTags(data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch tags:', err);
|
||||
toast.error('Failed to load tags');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!newTagName.trim()) {
|
||||
toast.error('Tag name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
toast.error('Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('tags')
|
||||
.insert({
|
||||
user_id: user.id,
|
||||
name: newTagName.trim(),
|
||||
color: selectedColor,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Tag created successfully');
|
||||
setDialogOpen(false);
|
||||
setNewTagName('');
|
||||
setSelectedColor(PRESET_COLORS[3].value);
|
||||
await fetchTags();
|
||||
} catch (err) {
|
||||
console.error('Create error:', err);
|
||||
toast.error('Failed to create tag');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(tag: Tag) {
|
||||
setTagToDelete(tag);
|
||||
setDeleteDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!tagToDelete) return;
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
const { error } = await supabase
|
||||
.from('tags')
|
||||
.delete()
|
||||
.eq('id', tagToDelete.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Tag deleted');
|
||||
setTags((prev) => prev.filter((t) => t.id !== tagToDelete.id));
|
||||
setDeleteDialogOpen(false);
|
||||
setTagToDelete(null);
|
||||
} catch (err) {
|
||||
console.error('Delete error:', err);
|
||||
toast.error('Failed to delete tag');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-emerald-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Tags</h2>
|
||||
<p className="text-sm text-slate-400">Organize your contacts with color-coded tags.</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setNewTagName('');
|
||||
setSelectedColor(PRESET_COLORS[3].value);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New Tag
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{tags.length === 0 ? (
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-slate-400 text-sm">No tags yet.</p>
|
||||
<p className="text-slate-500 text-xs mt-1">Create tags to categorize your contacts.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<CardContent className="pt-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="group inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
|
||||
style={{
|
||||
backgroundColor: `${tag.color}20`,
|
||||
color: tag.color,
|
||||
border: `1px solid ${tag.color}40`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
{tag.name}
|
||||
<button
|
||||
onClick={() => confirmDelete(tag)}
|
||||
className="ml-0.5 rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white/10"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* New Tag Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">New Tag</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Create a new tag with a name and color.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Tag Name</Label>
|
||||
<Input
|
||||
placeholder="e.g. VIP Customer"
|
||||
value={newTagName}
|
||||
onChange={(e) => setNewTagName(e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Color</Label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
onClick={() => setSelectedColor(color.value)}
|
||||
className="relative size-8 rounded-full transition-transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900"
|
||||
style={{
|
||||
backgroundColor: color.value,
|
||||
boxShadow: selectedColor === color.value ? `0 0 0 2px rgb(15 23 42), 0 0 0 4px ${color.value}` : 'none',
|
||||
}}
|
||||
title={color.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Preview</Label>
|
||||
<div>
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: `${selectedColor}20`,
|
||||
color: selectedColor,
|
||||
border: `1px solid ${selectedColor}40`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="size-2 rounded-full"
|
||||
style={{ backgroundColor: selectedColor }}
|
||||
/>
|
||||
{newTagName || 'Tag Name'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Tag'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">Delete Tag</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Are you sure you want to delete the tag "{tagToDelete?.name}"? This will remove
|
||||
it from all contacts. This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Delete Tag'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
362
src/components/settings/template-manager.tsx
Normal file
362
src/components/settings/template-manager.tsx
Normal file
@ -0,0 +1,362 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { MessageTemplate } from '@/types';
|
||||
|
||||
const CATEGORIES = ['Marketing', 'Utility', 'Authentication'] as const;
|
||||
const HEADER_TYPES = ['text', 'image', 'video', 'document'] as const;
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
Marketing: 'bg-purple-600/20 text-purple-400 border-purple-600/30',
|
||||
Utility: 'bg-blue-600/20 text-blue-400 border-blue-600/30',
|
||||
Authentication: 'bg-amber-600/20 text-amber-400 border-amber-600/30',
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
Draft: 'bg-slate-600/20 text-slate-400 border-slate-600/30',
|
||||
Pending: 'bg-yellow-600/20 text-yellow-400 border-yellow-600/30',
|
||||
Approved: 'bg-emerald-600/20 text-emerald-400 border-emerald-600/30',
|
||||
Rejected: 'bg-red-600/20 text-red-400 border-red-600/30',
|
||||
};
|
||||
|
||||
interface TemplateFormData {
|
||||
name: string;
|
||||
category: MessageTemplate['category'];
|
||||
language: string;
|
||||
body_text: string;
|
||||
header_type: string;
|
||||
footer_text: string;
|
||||
}
|
||||
|
||||
const emptyForm: TemplateFormData = {
|
||||
name: '',
|
||||
category: 'Marketing',
|
||||
language: 'en',
|
||||
body_text: '',
|
||||
header_type: '',
|
||||
footer_text: '',
|
||||
};
|
||||
|
||||
export function TemplateManager() {
|
||||
const supabase = createClient();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState<TemplateFormData>(emptyForm);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
async function fetchTemplates() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('message_templates')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setTemplates(data || []);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch templates:', err);
|
||||
toast.error('Failed to load templates');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.name.trim()) {
|
||||
toast.error('Template name is required');
|
||||
return;
|
||||
}
|
||||
if (!form.body_text.trim()) {
|
||||
toast.error('Body text is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
toast.error('Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
user_id: user.id,
|
||||
name: form.name.trim(),
|
||||
category: form.category,
|
||||
language: form.language.trim() || 'en',
|
||||
body_text: form.body_text.trim(),
|
||||
header_type: form.header_type || null,
|
||||
footer_text: form.footer_text.trim() || null,
|
||||
status: 'Draft' as const,
|
||||
};
|
||||
|
||||
const { error } = await supabase
|
||||
.from('message_templates')
|
||||
.insert(payload);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Template created successfully');
|
||||
setDialogOpen(false);
|
||||
setForm(emptyForm);
|
||||
await fetchTemplates();
|
||||
} catch (err) {
|
||||
console.error('Save error:', err);
|
||||
toast.error('Failed to create template');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('message_templates')
|
||||
.delete()
|
||||
.eq('id', id);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Template deleted');
|
||||
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||
} catch (err) {
|
||||
console.error('Delete error:', err);
|
||||
toast.error('Failed to delete template');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-emerald-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Message Templates</h2>
|
||||
<p className="text-sm text-slate-400">Create and manage your WhatsApp message templates.</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
New Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{templates.length === 0 ? (
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<p className="text-slate-400 text-sm">No templates yet.</p>
|
||||
<p className="text-slate-500 text-xs mt-1">Create your first message template to get started.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3">
|
||||
{templates.map((template) => (
|
||||
<Card key={template.id} className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<CardContent className="flex items-start justify-between pt-4">
|
||||
<div className="space-y-2 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="font-medium text-white">{template.name}</h3>
|
||||
<Badge
|
||||
className={`text-xs border ${categoryColors[template.category] || ''}`}
|
||||
>
|
||||
{template.category}
|
||||
</Badge>
|
||||
<Badge
|
||||
className={`text-xs border ${statusColors[template.status || 'Draft'] || ''}`}
|
||||
>
|
||||
{template.status || 'Draft'}
|
||||
</Badge>
|
||||
{template.language && (
|
||||
<span className="text-xs text-slate-500 uppercase">{template.language}</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-slate-400 line-clamp-2">{template.body_text}</p>
|
||||
{template.footer_text && (
|
||||
<p className="text-xs text-slate-500 italic">{template.footer_text}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(template.id)}
|
||||
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 shrink-0 ml-2"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Template Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">New Message Template</DialogTitle>
|
||||
<DialogDescription className="text-slate-400">
|
||||
Create a new WhatsApp message template.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Template Name</Label>
|
||||
<Input
|
||||
placeholder="e.g. order_confirmation"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Category</Label>
|
||||
<Select
|
||||
value={form.category}
|
||||
onValueChange={(val) =>
|
||||
setForm({ ...form, category: val as MessageTemplate['category'] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
{CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat} value={cat} className="text-white focus:bg-slate-700 focus:text-white">
|
||||
{cat}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Language</Label>
|
||||
<Input
|
||||
placeholder="en"
|
||||
value={form.language}
|
||||
onChange={(e) => setForm({ ...form, language: e.target.value })}
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Header Type</Label>
|
||||
<Select
|
||||
value={form.header_type}
|
||||
onValueChange={(val) => setForm({ ...form, header_type: val || '' })}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
|
||||
<SelectValue placeholder="None" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-slate-800 border-slate-700">
|
||||
<SelectItem value="none" className="text-white focus:bg-slate-700 focus:text-white">
|
||||
None
|
||||
</SelectItem>
|
||||
{HEADER_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type} className="text-white focus:bg-slate-700 focus:text-white">
|
||||
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Body Text</Label>
|
||||
<Textarea
|
||||
placeholder="Enter your template message body. Use {{1}}, {{2}} for variables."
|
||||
value={form.body_text}
|
||||
onChange={(e) => setForm({ ...form, body_text: e.target.value })}
|
||||
rows={4}
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Footer Text</Label>
|
||||
<Input
|
||||
placeholder="Optional footer text"
|
||||
value={form.footer_text}
|
||||
onChange={(e) => setForm({ ...form, footer_text: e.target.value })}
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDialogOpen(false)}
|
||||
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Template'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
447
src/components/settings/whatsapp-config.tsx
Normal file
447
src/components/settings/whatsapp-config.tsx
Normal file
@ -0,0 +1,447 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
AccordionContent,
|
||||
} from '@/components/ui/accordion';
|
||||
import type { WhatsAppConfig as WhatsAppConfigType } from '@/types';
|
||||
|
||||
const MASKED_TOKEN = '••••••••••••••••';
|
||||
|
||||
export function WhatsAppConfig() {
|
||||
const supabase = createClient();
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [config, setConfig] = useState<WhatsAppConfigType | null>(null);
|
||||
const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected' | 'unknown'>('unknown');
|
||||
|
||||
const [phoneNumberId, setPhoneNumberId] = useState('');
|
||||
const [wabaId, setWabaId] = useState('');
|
||||
const [accessToken, setAccessToken] = useState('');
|
||||
const [verifyToken, setVerifyToken] = useState('');
|
||||
const [tokenEdited, setTokenEdited] = useState(false);
|
||||
|
||||
const webhookUrl =
|
||||
typeof window !== 'undefined'
|
||||
? `${window.location.origin}/api/whatsapp/webhook`
|
||||
: '';
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
async function fetchConfig() {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.select('*')
|
||||
.eq('user_id', user.id)
|
||||
.single();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
setConfig(data);
|
||||
setPhoneNumberId(data.phone_number_id || '');
|
||||
setWabaId(data.waba_id || '');
|
||||
setAccessToken(MASKED_TOKEN);
|
||||
setVerifyToken(data.verify_token || '');
|
||||
setConnectionStatus(data.status || 'disconnected');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch config:', err);
|
||||
toast.error('Failed to load WhatsApp configuration');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!phoneNumberId.trim()) {
|
||||
toast.error('Phone Number ID is required');
|
||||
return;
|
||||
}
|
||||
if (!tokenEdited && !config) {
|
||||
toast.error('Access Token is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) {
|
||||
toast.error('Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
user_id: user.id,
|
||||
phone_number_id: phoneNumberId.trim(),
|
||||
waba_id: wabaId.trim() || null,
|
||||
verify_token: verifyToken.trim() || null,
|
||||
};
|
||||
|
||||
if (tokenEdited && accessToken !== MASKED_TOKEN) {
|
||||
payload.access_token = accessToken.trim();
|
||||
}
|
||||
|
||||
if (config?.id) {
|
||||
const { error } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.update(payload)
|
||||
.eq('id', config.id);
|
||||
if (error) throw error;
|
||||
} else {
|
||||
if (!tokenEdited || !accessToken.trim() || accessToken === MASKED_TOKEN) {
|
||||
toast.error('Access Token is required for initial setup');
|
||||
return;
|
||||
}
|
||||
payload.access_token = accessToken.trim();
|
||||
payload.status = 'disconnected';
|
||||
const { error } = await supabase
|
||||
.from('whatsapp_config')
|
||||
.insert(payload);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
toast.success('Configuration saved successfully');
|
||||
await fetchConfig();
|
||||
} catch (err) {
|
||||
console.error('Save error:', err);
|
||||
toast.error('Failed to save configuration');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTestConnection() {
|
||||
try {
|
||||
setTesting(true);
|
||||
const response = await fetch('/api/whatsapp/config', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setConnectionStatus('connected');
|
||||
toast.success('API connection successful');
|
||||
|
||||
if (config?.id) {
|
||||
await supabase
|
||||
.from('whatsapp_config')
|
||||
.update({ status: 'connected', connected_at: new Date().toISOString() })
|
||||
.eq('id', config.id);
|
||||
}
|
||||
} else {
|
||||
setConnectionStatus('disconnected');
|
||||
toast.error('API connection failed. Check your credentials.');
|
||||
}
|
||||
} catch {
|
||||
setConnectionStatus('disconnected');
|
||||
toast.error('Connection test failed. Ensure your server is running.');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyWebhookUrl() {
|
||||
navigator.clipboard.writeText(webhookUrl);
|
||||
toast.success('Webhook URL copied to clipboard');
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="size-6 animate-spin text-emerald-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-6 lg:grid-cols-[1fr_380px] mt-4">
|
||||
{/* Main config form */}
|
||||
<div className="space-y-6">
|
||||
{/* Connection Status */}
|
||||
<Alert className="bg-slate-900 border-slate-700">
|
||||
<div className="flex items-center gap-2">
|
||||
{connectionStatus === 'connected' ? (
|
||||
<CheckCircle2 className="size-4 text-emerald-500" />
|
||||
) : (
|
||||
<XCircle className="size-4 text-red-500" />
|
||||
)}
|
||||
<AlertTitle className="text-white mb-0">
|
||||
{connectionStatus === 'connected' ? 'Connected' : 'Not Connected'}
|
||||
</AlertTitle>
|
||||
</div>
|
||||
<AlertDescription className="text-slate-400">
|
||||
{connectionStatus === 'connected'
|
||||
? 'Your WhatsApp Business API is connected and ready to send/receive messages.'
|
||||
: 'Configure your Meta API credentials below to connect your WhatsApp Business account.'}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* API Credentials */}
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">API Credentials</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Enter your Meta WhatsApp Business API credentials.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Phone Number ID</Label>
|
||||
<Input
|
||||
placeholder="e.g. 100234567890123"
|
||||
value={phoneNumberId}
|
||||
onChange={(e) => setPhoneNumberId(e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">WhatsApp Business Account ID</Label>
|
||||
<Input
|
||||
placeholder="e.g. 100234567890456"
|
||||
value={wabaId}
|
||||
onChange={(e) => setWabaId(e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Permanent Access Token</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showToken ? 'text' : 'password'}
|
||||
placeholder="Enter your access token"
|
||||
value={accessToken}
|
||||
onChange={(e) => {
|
||||
setAccessToken(e.target.value);
|
||||
setTokenEdited(true);
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (accessToken === MASKED_TOKEN) {
|
||||
setAccessToken('');
|
||||
setTokenEdited(true);
|
||||
}
|
||||
}}
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
|
||||
>
|
||||
{showToken ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Webhook Verify Token</Label>
|
||||
<Input
|
||||
placeholder="Create a custom verify token"
|
||||
value={verifyToken}
|
||||
onChange={(e) => setVerifyToken(e.target.value)}
|
||||
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||
/>
|
||||
<p className="text-xs text-slate-500">
|
||||
A custom string you create. Must match the token you set in Meta webhook settings.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Webhook URL */}
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Webhook Configuration</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Use this URL as your webhook callback in the Meta App Dashboard.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-slate-300">Webhook Callback URL</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
readOnly
|
||||
value={webhookUrl}
|
||||
className="bg-slate-800 border-slate-700 text-slate-300 font-mono text-sm"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCopyWebhookUrl}
|
||||
className="shrink-0 border-slate-700 text-slate-300 hover:text-white hover:bg-slate-800"
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Configuration'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleTestConnection}
|
||||
disabled={testing || !config}
|
||||
className="border-slate-700 text-slate-300 hover:text-white hover:bg-slate-800"
|
||||
>
|
||||
{testing ? (
|
||||
<>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Zap className="size-4" />
|
||||
Test API Connection
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Setup Instructions Sidebar */}
|
||||
<div>
|
||||
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white text-base">Setup Instructions</CardTitle>
|
||||
<CardDescription className="text-slate-400">
|
||||
Follow these steps to connect your WhatsApp Business API.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion>
|
||||
<AccordionItem className="border-slate-700">
|
||||
<AccordionTrigger className="text-slate-300 hover:text-white hover:no-underline">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-emerald-600 text-xs font-bold text-white">1</span>
|
||||
Create a Meta App
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-slate-400">
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||
<li>Go to <span className="text-emerald-400">developers.facebook.com</span></li>
|
||||
<li>Click "My Apps" and then "Create App"</li>
|
||||
<li>Select "Business" as the app type</li>
|
||||
<li>Fill in app details and create</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem className="border-slate-700">
|
||||
<AccordionTrigger className="text-slate-300 hover:text-white hover:no-underline">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-emerald-600 text-xs font-bold text-white">2</span>
|
||||
Add WhatsApp Product
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-slate-400">
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||
<li>In your app dashboard, click "Add Product"</li>
|
||||
<li>Find "WhatsApp" and click "Set Up"</li>
|
||||
<li>Follow the setup wizard to link your business</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem className="border-slate-700">
|
||||
<AccordionTrigger className="text-slate-300 hover:text-white hover:no-underline">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-emerald-600 text-xs font-bold text-white">3</span>
|
||||
Get API Credentials
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-slate-400">
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||
<li>Go to WhatsApp > API Setup</li>
|
||||
<li>Copy your <strong className="text-slate-200">Phone Number ID</strong></li>
|
||||
<li>Copy your <strong className="text-slate-200">WhatsApp Business Account ID</strong></li>
|
||||
<li>Generate a <strong className="text-slate-200">Permanent Access Token</strong> from Business Settings > System Users</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem className="border-slate-700">
|
||||
<AccordionTrigger className="text-slate-300 hover:text-white hover:no-underline">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="flex size-5 items-center justify-center rounded-full bg-emerald-600 text-xs font-bold text-white">4</span>
|
||||
Configure Webhooks
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="text-slate-400">
|
||||
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||
<li>Go to WhatsApp > Configuration</li>
|
||||
<li>Click "Edit" on the Webhook section</li>
|
||||
<li>Paste the <strong className="text-slate-200">Webhook Callback URL</strong> from above</li>
|
||||
<li>Enter the same <strong className="text-slate-200">Verify Token</strong> you set here</li>
|
||||
<li>Subscribe to "messages" webhook field</li>
|
||||
</ol>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-slate-700">
|
||||
<a
|
||||
href="https://developers.facebook.com/docs/whatsapp/cloud-api/get-started"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 text-sm text-emerald-400 hover:text-emerald-300 transition-colors"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
Meta WhatsApp API Documentation
|
||||
</a>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/components/ui/accordion.tsx
Normal file
72
src/components/ui/accordion.tsx
Normal file
@ -0,0 +1,72 @@
|
||||
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Root
|
||||
data-slot="accordion"
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Item
|
||||
data-slot="accordion-item"
|
||||
className={cn("not-last:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Header className="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
className={cn(
|
||||
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
||||
)
|
||||
}
|
||||
|
||||
function AccordionContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: AccordionPrimitive.Panel.Props) {
|
||||
return (
|
||||
<AccordionPrimitive.Panel
|
||||
data-slot="accordion-content"
|
||||
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</AccordionPrimitive.Panel>
|
||||
)
|
||||
}
|
||||
|
||||
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||
76
src/components/ui/alert.tsx
Normal file
76
src/components/ui/alert.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-action"
|
||||
className={cn("absolute top-2 right-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||
109
src/components/ui/avatar.tsx
Normal file
109
src/components/ui/avatar.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
52
src/components/ui/badge.tsx
Normal file
52
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
103
src/components/ui/card.tsx
Normal file
103
src/components/ui/card.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
160
src/components/ui/dialog.tsx
Normal file
160
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,160 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
268
src/components/ui/dropdown-menu.tsx
Normal file
268
src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,268 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
src/components/ui/label.tsx
Normal file
20
src/components/ui/label.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
90
src/components/ui/popover.tsx
Normal file
90
src/components/ui/popover.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-heading font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
38
src/components/ui/radio-group.tsx
Normal file
38
src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
|
||||
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
|
||||
return (
|
||||
<RadioGroupPrimitive
|
||||
data-slot="radio-group"
|
||||
className={cn("grid w-full gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
|
||||
return (
|
||||
<RadioPrimitive.Root
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="flex size-4 items-center justify-center"
|
||||
>
|
||||
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
|
||||
</RadioPrimitive.Indicator>
|
||||
</RadioPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
55
src/components/ui/scroll-area.tsx
Normal file
55
src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
201
src/components/ui/select.tsx
Normal file
201
src/components/ui/select.tsx
Normal file
@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
25
src/components/ui/separator.tsx
Normal file
25
src/components/ui/separator.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
"use client"
|
||||
|
||||
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: SeparatorPrimitive.Props) {
|
||||
return (
|
||||
<SeparatorPrimitive
|
||||
data-slot="separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
138
src/components/ui/sheet.tsx
Normal file
138
src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
116
src/components/ui/table.tsx
Normal file
116
src/components/ui/table.tsx
Normal file
@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
82
src/components/ui/tabs.tsx
Normal file
82
src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
"use client"
|
||||
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
66
src/components/ui/tooltip.tsx
Normal file
66
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
"use client"
|
||||
|
||||
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delay = 0,
|
||||
...props
|
||||
}: TooltipPrimitive.Provider.Props) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delay={delay}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: TooltipPrimitive.Popup.Props &
|
||||
Pick<
|
||||
TooltipPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<TooltipPrimitive.Popup
|
||||
data-slot="tooltip-content"
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||
</TooltipPrimitive.Popup>
|
||||
</TooltipPrimitive.Positioner>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
81
src/hooks/use-auth.ts
Normal file
81
src/hooks/use-auth.ts
Normal file
@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { User } from "@supabase/supabase-js";
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
full_name: string | null;
|
||||
email: string;
|
||||
avatar_url: string | null;
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
const fetchProfile = useCallback(
|
||||
async (userId: string) => {
|
||||
const { data } = await supabase
|
||||
.from("profiles")
|
||||
.select("id, full_name, email, avatar_url")
|
||||
.eq("id", userId)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setProfile(data);
|
||||
}
|
||||
},
|
||||
[supabase]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const getUser = async () => {
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
setUser(user);
|
||||
|
||||
if (user) {
|
||||
await fetchProfile(user.id);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
getUser();
|
||||
|
||||
const {
|
||||
data: { subscription },
|
||||
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||
const currentUser = session?.user ?? null;
|
||||
setUser(currentUser);
|
||||
|
||||
if (currentUser) {
|
||||
await fetchProfile(currentUser.id);
|
||||
} else {
|
||||
setProfile(null);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.unsubscribe();
|
||||
};
|
||||
}, [supabase, fetchProfile]);
|
||||
|
||||
const signOut = useCallback(async () => {
|
||||
await supabase.auth.signOut();
|
||||
setUser(null);
|
||||
setProfile(null);
|
||||
window.location.href = "/login";
|
||||
}, [supabase]);
|
||||
|
||||
return { user, profile, loading, signOut };
|
||||
}
|
||||
263
src/hooks/use-broadcast-sending.ts
Normal file
263
src/hooks/use-broadcast-sending.ts
Normal file
@ -0,0 +1,263 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
import { Contact, MessageTemplate } from '@/types';
|
||||
|
||||
interface AudienceConfig {
|
||||
type: 'all' | 'tags' | 'csv';
|
||||
tagIds?: string[];
|
||||
csvContacts?: { phone: string; name?: string }[];
|
||||
}
|
||||
|
||||
interface BroadcastPayload {
|
||||
name: string;
|
||||
template: MessageTemplate;
|
||||
audience: AudienceConfig;
|
||||
variables: Record<string, { type: 'static' | 'field'; value: string }>;
|
||||
}
|
||||
|
||||
interface UseBroadcastSendingReturn {
|
||||
createAndSendBroadcast: (payload: BroadcastPayload) => Promise<string>;
|
||||
isProcessing: boolean;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export function useBroadcastSending(): UseBroadcastSendingReturn {
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
async function resolveAudience(audience: AudienceConfig): Promise<Contact[]> {
|
||||
const supabase = createClient();
|
||||
|
||||
if (audience.type === 'all') {
|
||||
const { data, error } = await supabase.from('contacts').select('*');
|
||||
if (error) throw new Error(`Failed to fetch contacts: ${error.message}`);
|
||||
return data ?? [];
|
||||
}
|
||||
|
||||
if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
|
||||
const { data: contactTags, error: tagError } = await supabase
|
||||
.from('contact_tags')
|
||||
.select('contact_id')
|
||||
.in('tag_id', audience.tagIds);
|
||||
|
||||
if (tagError) throw new Error(`Failed to fetch contact tags: ${tagError.message}`);
|
||||
if (!contactTags || contactTags.length === 0) return [];
|
||||
|
||||
const uniqueContactIds = [...new Set(contactTags.map((ct) => ct.contact_id))];
|
||||
|
||||
const { data: contacts, error: contactError } = await supabase
|
||||
.from('contacts')
|
||||
.select('*')
|
||||
.in('id', uniqueContactIds);
|
||||
|
||||
if (contactError) throw new Error(`Failed to fetch contacts: ${contactError.message}`);
|
||||
return contacts ?? [];
|
||||
}
|
||||
|
||||
if (audience.type === 'csv' && audience.csvContacts) {
|
||||
return audience.csvContacts.map((c, i) => ({
|
||||
id: `csv-${i}`,
|
||||
user_id: '',
|
||||
phone: c.phone,
|
||||
name: c.name,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function resolveVariables(
|
||||
variables: Record<string, { type: 'static' | 'field'; value: string }>,
|
||||
contact: Contact
|
||||
): string[] {
|
||||
return Object.keys(variables)
|
||||
.sort()
|
||||
.map((key) => {
|
||||
const v = variables[key];
|
||||
if (v.type === 'static') return v.value;
|
||||
const fieldMap: Record<string, string | undefined> = {
|
||||
name: contact.name,
|
||||
phone: contact.phone,
|
||||
email: contact.email,
|
||||
company: contact.company,
|
||||
};
|
||||
return fieldMap[v.value] ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
async function createAndSendBroadcast(payload: BroadcastPayload): Promise<string> {
|
||||
setIsProcessing(true);
|
||||
setProgress(0);
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
try {
|
||||
// Step 1: Resolve audience contacts
|
||||
setProgress(5);
|
||||
const contacts = await resolveAudience(payload.audience);
|
||||
|
||||
if (contacts.length === 0) {
|
||||
throw new Error('No contacts found for this audience.');
|
||||
}
|
||||
|
||||
// Step 2: Create broadcast record
|
||||
setProgress(10);
|
||||
const { data: broadcast, error: broadcastError } = await supabase
|
||||
.from('broadcasts')
|
||||
.insert({
|
||||
name: payload.name,
|
||||
template_name: payload.template.name,
|
||||
template_language: payload.template.language ?? 'en_US',
|
||||
template_variables: payload.variables,
|
||||
audience_filter: {
|
||||
type: payload.audience.type,
|
||||
tagIds: payload.audience.tagIds,
|
||||
},
|
||||
status: 'sending',
|
||||
total_recipients: contacts.length,
|
||||
sent_count: 0,
|
||||
delivered_count: 0,
|
||||
read_count: 0,
|
||||
replied_count: 0,
|
||||
failed_count: 0,
|
||||
})
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (broadcastError || !broadcast) {
|
||||
throw new Error(`Failed to create broadcast: ${broadcastError?.message}`);
|
||||
}
|
||||
|
||||
// Step 3: Create broadcast recipients in batches
|
||||
setProgress(20);
|
||||
const BATCH_SIZE = 50;
|
||||
const recipientRows = contacts.map((contact) => ({
|
||||
broadcast_id: broadcast.id,
|
||||
contact_id: contact.id,
|
||||
status: 'pending' as const,
|
||||
}));
|
||||
|
||||
for (let i = 0; i < recipientRows.length; i += BATCH_SIZE) {
|
||||
const batch = recipientRows.slice(i, i + BATCH_SIZE);
|
||||
const { error: recipientError } = await supabase
|
||||
.from('broadcast_recipients')
|
||||
.insert(batch);
|
||||
|
||||
if (recipientError) {
|
||||
console.error('Failed to insert recipient batch:', recipientError);
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: Fetch recipients and send via API in batches
|
||||
setProgress(30);
|
||||
const { data: recipients, error: recipientsFetchError } = await supabase
|
||||
.from('broadcast_recipients')
|
||||
.select('*, contact:contacts(*)')
|
||||
.eq('broadcast_id', broadcast.id);
|
||||
|
||||
if (recipientsFetchError || !recipients) {
|
||||
throw new Error('Failed to fetch broadcast recipients');
|
||||
}
|
||||
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
const totalRecipients = recipients.length;
|
||||
|
||||
for (let i = 0; i < recipients.length; i += BATCH_SIZE) {
|
||||
const batch = recipients.slice(i, i + BATCH_SIZE);
|
||||
const phoneNumbers = batch
|
||||
.map((r) => r.contact?.phone)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
if (phoneNumbers.length === 0) continue;
|
||||
|
||||
// Resolve template params from the first contact in batch for static values
|
||||
const templateParams = batch.map((r) => {
|
||||
if (r.contact) {
|
||||
return resolveVariables(payload.variables, r.contact);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/whatsapp/broadcast', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
phone_numbers: phoneNumbers,
|
||||
template_name: payload.template.name,
|
||||
template_language: payload.template.language ?? 'en_US',
|
||||
template_params: templateParams[0] ?? [],
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Broadcast API request failed');
|
||||
}
|
||||
|
||||
// Update individual recipient statuses based on results
|
||||
if (data.results) {
|
||||
for (let j = 0; j < data.results.length; j++) {
|
||||
const result = data.results[j];
|
||||
const recipient = batch[j];
|
||||
if (!recipient) continue;
|
||||
|
||||
const newStatus = result.status === 'sent' ? 'sent' : 'failed';
|
||||
if (newStatus === 'sent') sentCount++;
|
||||
else failedCount++;
|
||||
|
||||
await supabase
|
||||
.from('broadcast_recipients')
|
||||
.update({
|
||||
status: newStatus,
|
||||
sent_at: newStatus === 'sent' ? new Date().toISOString() : null,
|
||||
error_message: result.error ?? null,
|
||||
})
|
||||
.eq('id', recipient.id);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Mark entire batch as failed
|
||||
for (const recipient of batch) {
|
||||
failedCount++;
|
||||
await supabase
|
||||
.from('broadcast_recipients')
|
||||
.update({
|
||||
status: 'failed',
|
||||
error_message: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
.eq('id', recipient.id);
|
||||
}
|
||||
}
|
||||
|
||||
const progressPct = 30 + Math.round(((i + batch.length) / totalRecipients) * 60);
|
||||
setProgress(progressPct);
|
||||
}
|
||||
|
||||
// Step 5: Update broadcast with final counts
|
||||
setProgress(95);
|
||||
const finalStatus = failedCount === totalRecipients ? 'failed' : 'sent';
|
||||
await supabase
|
||||
.from('broadcasts')
|
||||
.update({
|
||||
status: finalStatus,
|
||||
sent_count: sentCount,
|
||||
failed_count: failedCount,
|
||||
})
|
||||
.eq('id', broadcast.id);
|
||||
|
||||
setProgress(100);
|
||||
return broadcast.id;
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
}
|
||||
|
||||
return { createAndSendBroadcast, isProcessing, progress };
|
||||
}
|
||||
89
src/hooks/use-realtime.ts
Normal file
89
src/hooks/use-realtime.ts
Normal file
@ -0,0 +1,89 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import type { Message, Conversation } from "@/types";
|
||||
import type { RealtimeChannel } from "@supabase/supabase-js";
|
||||
|
||||
interface RealtimeEvent<T> {
|
||||
eventType: "INSERT" | "UPDATE" | "DELETE";
|
||||
new: T;
|
||||
old: Partial<T>;
|
||||
}
|
||||
|
||||
interface UseRealtimeOptions {
|
||||
channelName: string;
|
||||
onMessageEvent?: (event: RealtimeEvent<Message>) => void;
|
||||
onConversationEvent?: (event: RealtimeEvent<Conversation>) => void;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useRealtime({
|
||||
channelName,
|
||||
onMessageEvent,
|
||||
onConversationEvent,
|
||||
enabled = true,
|
||||
}: UseRealtimeOptions) {
|
||||
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
// Store latest callbacks in refs to avoid re-subscribing
|
||||
const onMessageRef = useRef(onMessageEvent);
|
||||
onMessageRef.current = onMessageEvent;
|
||||
|
||||
const onConversationRef = useRef(onConversationEvent);
|
||||
onConversationRef.current = onConversationEvent;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const supabase = createClient();
|
||||
|
||||
const channel = supabase
|
||||
.channel(channelName)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "messages" },
|
||||
(payload) => {
|
||||
onMessageRef.current?.({
|
||||
eventType: payload.eventType as RealtimeEvent<Message>["eventType"],
|
||||
new: payload.new as Message,
|
||||
old: payload.old as Partial<Message>,
|
||||
});
|
||||
}
|
||||
)
|
||||
.on(
|
||||
"postgres_changes",
|
||||
{ event: "*", schema: "public", table: "conversations" },
|
||||
(payload) => {
|
||||
onConversationRef.current?.({
|
||||
eventType: payload.eventType as RealtimeEvent<Conversation>["eventType"],
|
||||
new: payload.new as Conversation,
|
||||
old: payload.old as Partial<Conversation>,
|
||||
});
|
||||
}
|
||||
)
|
||||
.subscribe((status) => {
|
||||
setIsConnected(status === "SUBSCRIBED");
|
||||
});
|
||||
|
||||
channelRef.current = channel;
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
channelRef.current = null;
|
||||
setIsConnected(false);
|
||||
};
|
||||
}, [channelName, enabled]);
|
||||
|
||||
const unsubscribe = useCallback(() => {
|
||||
if (channelRef.current) {
|
||||
const supabase = createClient();
|
||||
supabase.removeChannel(channelRef.current);
|
||||
channelRef.current = null;
|
||||
setIsConnected(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isConnected, unsubscribe };
|
||||
}
|
||||
8
src/lib/supabase/client.ts
Normal file
8
src/lib/supabase/client.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { createBrowserClient } from '@supabase/ssr'
|
||||
|
||||
export function createClient() {
|
||||
return createBrowserClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||
)
|
||||
}
|
||||
28
src/lib/supabase/server.ts
Normal file
28
src/lib/supabase/server.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { cookies } from 'next/headers'
|
||||
|
||||
export async function createClient() {
|
||||
const cookieStore = await cookies()
|
||||
|
||||
return createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return cookieStore.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
try {
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
cookieStore.set(name, value, options)
|
||||
)
|
||||
} catch {
|
||||
// The `setAll` method was called from a Server Component.
|
||||
// This can be ignored if you have middleware refreshing sessions.
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
29
src/lib/whatsapp/encryption.ts
Normal file
29
src/lib/whatsapp/encryption.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!
|
||||
const IV_LENGTH = 16
|
||||
|
||||
export function encrypt(text: string): string {
|
||||
const iv = crypto.randomBytes(IV_LENGTH)
|
||||
const cipher = crypto.createCipheriv(
|
||||
'aes-256-cbc',
|
||||
Buffer.from(ENCRYPTION_KEY, 'hex'),
|
||||
iv
|
||||
)
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||
encrypted += cipher.final('hex')
|
||||
return iv.toString('hex') + ':' + encrypted
|
||||
}
|
||||
|
||||
export function decrypt(encryptedText: string): string {
|
||||
const parts = encryptedText.split(':')
|
||||
const iv = Buffer.from(parts[0], 'hex')
|
||||
const decipher = crypto.createDecipheriv(
|
||||
'aes-256-cbc',
|
||||
Buffer.from(ENCRYPTION_KEY, 'hex'),
|
||||
iv
|
||||
)
|
||||
let decrypted = decipher.update(parts[1], 'hex', 'utf8')
|
||||
decrypted += decipher.final('utf8')
|
||||
return decrypted
|
||||
}
|
||||
138
src/lib/whatsapp/meta-api.ts
Normal file
138
src/lib/whatsapp/meta-api.ts
Normal file
@ -0,0 +1,138 @@
|
||||
const META_API_VERSION = 'v21.0'
|
||||
const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}`
|
||||
|
||||
export interface MetaSendResult {
|
||||
messageId: string
|
||||
}
|
||||
|
||||
export interface MetaPhoneInfo {
|
||||
id: string
|
||||
display_phone_number: string
|
||||
verified_name?: string
|
||||
quality_rating?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify phone number ID with Meta Graph API
|
||||
*/
|
||||
export async function verifyPhoneNumber(phoneNumberId: string, accessToken: string): Promise<MetaPhoneInfo> {
|
||||
const url = `${META_API_BASE}/${phoneNumberId}?fields=id,display_phone_number,verified_name,quality_rating`
|
||||
const response = await fetch(url, {
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error?.message || `Meta API error: ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a text message via Meta WhatsApp API
|
||||
*/
|
||||
export async function sendTextMessage(
|
||||
phoneNumberId: string,
|
||||
accessToken: string,
|
||||
to: string,
|
||||
text: string
|
||||
): Promise<MetaSendResult> {
|
||||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual',
|
||||
to,
|
||||
type: 'text',
|
||||
text: { body: text },
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error?.message || `Meta API error: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return { messageId: data.messages[0].id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a template message via Meta WhatsApp API
|
||||
*/
|
||||
export async function sendTemplateMessage(
|
||||
phoneNumberId: string,
|
||||
accessToken: string,
|
||||
to: string,
|
||||
templateName: string,
|
||||
language: string = 'en_US',
|
||||
params?: string[]
|
||||
): Promise<MetaSendResult> {
|
||||
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||||
|
||||
const template: Record<string, unknown> = {
|
||||
name: templateName,
|
||||
language: { code: language },
|
||||
}
|
||||
|
||||
if (params && params.length > 0) {
|
||||
template.components = [{
|
||||
type: 'body',
|
||||
parameters: params.map(p => ({ type: 'text', text: String(p) })),
|
||||
}]
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messaging_product: 'whatsapp',
|
||||
recipient_type: 'individual',
|
||||
to,
|
||||
type: 'template',
|
||||
template,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error?.message || `Meta API error: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
return { messageId: data.messages[0].id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get media download URL from Meta
|
||||
*/
|
||||
export async function getMediaUrl(mediaId: string, accessToken: string): Promise<{ url: string; mimeType: string }> {
|
||||
const response = await fetch(`${META_API_BASE}/${mediaId}`, {
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.error?.message || `Media fetch failed: ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
if (!data.url) throw new Error('Media URL not found')
|
||||
return { url: data.url, mimeType: data.mime_type || 'application/octet-stream' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Download media binary from Meta CDN
|
||||
*/
|
||||
export async function downloadMedia(downloadUrl: string, accessToken: string): Promise<{ buffer: Buffer; contentType: string }> {
|
||||
const response = await fetch(downloadUrl, {
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error(`Media download failed: ${response.status}`)
|
||||
}
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream'
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
return { buffer, contentType }
|
||||
}
|
||||
41
src/lib/whatsapp/phone-utils.ts
Normal file
41
src/lib/whatsapp/phone-utils.ts
Normal file
@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Sanitize phone number for Meta WhatsApp API.
|
||||
* Meta requires digits only — no + prefix, no spaces, no dashes.
|
||||
* e.g. "+370 63949836" → "37063949836"
|
||||
*/
|
||||
export function sanitizePhoneForMeta(phone: string): string {
|
||||
if (!phone) return ''
|
||||
return phone.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize phone number by removing all non-digit characters.
|
||||
* Used for comparing phone numbers in different formats.
|
||||
*/
|
||||
export function normalizePhone(phone: string): string {
|
||||
if (!phone) return ''
|
||||
return phone.replace(/\D/g, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two phone numbers accounting for trunk prefix differences.
|
||||
* e.g. "370063949836" (with trunk 0) matches "37063949836" (without trunk 0)
|
||||
* by comparing the last 8 digits.
|
||||
*/
|
||||
export function phonesMatch(phone1: string, phone2: string): boolean {
|
||||
const n1 = normalizePhone(phone1)
|
||||
const n2 = normalizePhone(phone2)
|
||||
if (n1 === n2) return true
|
||||
if (n1.length >= 8 && n2.length >= 8) {
|
||||
return n1.slice(-8) === n2.slice(-8)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate phone number is E.164-like format (7-15 digits starting with non-zero).
|
||||
* Accepts with or without + prefix.
|
||||
*/
|
||||
export function isValidE164(phone: string): boolean {
|
||||
return /^\+?[1-9]\d{6,14}$/.test(phone)
|
||||
}
|
||||
60
src/middleware.ts
Normal file
60
src/middleware.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { createServerClient } from '@supabase/ssr'
|
||||
import { NextResponse, type NextRequest } from 'next/server'
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
let supabaseResponse = NextResponse.next({ request })
|
||||
|
||||
const supabase = createServerClient(
|
||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||
{
|
||||
cookies: {
|
||||
getAll() {
|
||||
return request.cookies.getAll()
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
|
||||
supabaseResponse = NextResponse.next({ request })
|
||||
cookiesToSet.forEach(({ name, value, options }) =>
|
||||
supabaseResponse.cookies.set(name, value, options)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const { data: { user } } = await supabase.auth.getUser()
|
||||
|
||||
// Auth pages - redirect to dashboard if already logged in
|
||||
if (user && (
|
||||
request.nextUrl.pathname === '/login' ||
|
||||
request.nextUrl.pathname === '/signup' ||
|
||||
request.nextUrl.pathname === '/forgot-password'
|
||||
)) {
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = '/dashboard'
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
|
||||
// Protected pages - redirect to login if not authenticated
|
||||
const protectedPaths = ['/dashboard', '/inbox', '/contacts', '/pipelines', '/broadcasts', '/automations', '/settings']
|
||||
if (!user && protectedPaths.some(path => request.nextUrl.pathname.startsWith(path))) {
|
||||
const url = request.nextUrl.clone()
|
||||
url.pathname = '/login'
|
||||
return NextResponse.redirect(url)
|
||||
}
|
||||
|
||||
// API routes that need auth (not webhooks)
|
||||
if (!user && request.nextUrl.pathname.startsWith('/api/whatsapp/') &&
|
||||
!request.nextUrl.pathname.includes('/webhook')) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
return supabaseResponse
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||
],
|
||||
}
|
||||
189
src/types/index.ts
Normal file
189
src/types/index.ts
Normal file
@ -0,0 +1,189 @@
|
||||
export interface Profile {
|
||||
id: string;
|
||||
user_id: string;
|
||||
full_name: string;
|
||||
email: string;
|
||||
avatar_url?: string;
|
||||
role: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Contact {
|
||||
id: string;
|
||||
user_id: string;
|
||||
phone: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
company?: string;
|
||||
avatar_url?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ContactTag {
|
||||
id: string;
|
||||
contact_id: string;
|
||||
tag_id: string;
|
||||
}
|
||||
|
||||
export interface CustomField {
|
||||
id: string;
|
||||
user_id: string;
|
||||
field_name: string;
|
||||
field_type: string;
|
||||
field_options?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ContactCustomValue {
|
||||
id: string;
|
||||
contact_id: string;
|
||||
custom_field_id: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
export interface ContactNote {
|
||||
id: string;
|
||||
contact_id: string;
|
||||
user_id: string;
|
||||
note_text: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type ConversationStatus = 'open' | 'pending' | 'closed';
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
user_id: string;
|
||||
contact_id: string;
|
||||
status: ConversationStatus;
|
||||
assigned_agent_id?: string;
|
||||
last_message_text?: string;
|
||||
last_message_at?: string;
|
||||
unread_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
contact?: Contact;
|
||||
}
|
||||
|
||||
export type SenderType = 'customer' | 'agent' | 'bot';
|
||||
export type ContentType = 'text' | 'image' | 'document' | 'audio' | 'video' | 'location' | 'template';
|
||||
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
sender_type: SenderType;
|
||||
sender_id?: string;
|
||||
content_type: ContentType;
|
||||
content_text?: string;
|
||||
media_url?: string;
|
||||
template_name?: string;
|
||||
message_id?: string;
|
||||
status: MessageStatus;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WhatsAppConfig {
|
||||
id: string;
|
||||
user_id: string;
|
||||
phone_number_id: string;
|
||||
waba_id?: string;
|
||||
access_token: string;
|
||||
verify_token?: string;
|
||||
status: 'connected' | 'disconnected';
|
||||
connected_at?: string;
|
||||
}
|
||||
|
||||
export interface MessageTemplate {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
category: 'Marketing' | 'Utility' | 'Authentication';
|
||||
language?: string;
|
||||
header_type?: 'text' | 'image' | 'video' | 'document';
|
||||
header_content?: string;
|
||||
body_text: string;
|
||||
footer_text?: string;
|
||||
buttons?: Record<string, unknown>[];
|
||||
status?: 'Draft' | 'Pending' | 'Approved' | 'Rejected';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Pipeline {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PipelineStage {
|
||||
id: string;
|
||||
pipeline_id: string;
|
||||
name: string;
|
||||
position: number;
|
||||
color: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Deal {
|
||||
id: string;
|
||||
user_id: string;
|
||||
pipeline_id: string;
|
||||
stage_id: string;
|
||||
contact_id: string;
|
||||
conversation_id?: string;
|
||||
title: string;
|
||||
value: number;
|
||||
currency?: string;
|
||||
notes?: string;
|
||||
expected_close_date?: string;
|
||||
status?: string;
|
||||
created_at: string;
|
||||
contact?: Contact;
|
||||
stage?: PipelineStage;
|
||||
}
|
||||
|
||||
export type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';
|
||||
export type RecipientStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'replied' | 'failed';
|
||||
|
||||
export interface Broadcast {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
template_name: string;
|
||||
template_language: string;
|
||||
template_variables?: Record<string, unknown>;
|
||||
audience_filter?: Record<string, unknown>;
|
||||
scheduled_at?: string;
|
||||
status: BroadcastStatus;
|
||||
total_recipients: number;
|
||||
sent_count: number;
|
||||
delivered_count: number;
|
||||
read_count: number;
|
||||
replied_count: number;
|
||||
failed_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface BroadcastRecipient {
|
||||
id: string;
|
||||
broadcast_id: string;
|
||||
contact_id: string;
|
||||
status: RecipientStatus;
|
||||
sent_at?: string;
|
||||
delivered_at?: string;
|
||||
read_at?: string;
|
||||
replied_at?: string;
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
contact?: Contact;
|
||||
}
|
||||
359
supabase/migrations/001_initial_schema.sql
Normal file
359
supabase/migrations/001_initial_schema.sql
Normal file
@ -0,0 +1,359 @@
|
||||
-- Enable UUID extension
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- ============================================================
|
||||
-- PROFILES
|
||||
-- ============================================================
|
||||
CREATE TABLE profiles (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
full_name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
avatar_url TEXT,
|
||||
role TEXT DEFAULT 'user',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can view own profile" ON profiles FOR SELECT USING (auth.uid() = user_id);
|
||||
CREATE POLICY "Users can update own profile" ON profiles FOR UPDATE USING (auth.uid() = user_id);
|
||||
CREATE POLICY "Users can insert own profile" ON profiles FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- CONTACTS
|
||||
-- ============================================================
|
||||
CREATE TABLE contacts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
phone TEXT NOT NULL,
|
||||
name TEXT,
|
||||
email TEXT,
|
||||
company TEXT,
|
||||
avatar_url TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_contacts_user_id ON contacts(user_id);
|
||||
CREATE INDEX idx_contacts_phone ON contacts(phone);
|
||||
|
||||
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage own contacts" ON contacts FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- TAGS
|
||||
-- ============================================================
|
||||
CREATE TABLE tags (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage own tags" ON tags FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- CONTACT_TAGS (many-to-many)
|
||||
-- ============================================================
|
||||
CREATE TABLE contact_tags (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(contact_id, tag_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_contact_tags_contact ON contact_tags(contact_id);
|
||||
CREATE INDEX idx_contact_tags_tag ON contact_tags(tag_id);
|
||||
|
||||
ALTER TABLE contact_tags ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage contact tags" ON contact_tags FOR ALL
|
||||
USING (EXISTS (SELECT 1 FROM contacts WHERE contacts.id = contact_tags.contact_id AND contacts.user_id = auth.uid()));
|
||||
|
||||
-- ============================================================
|
||||
-- CUSTOM_FIELDS
|
||||
-- ============================================================
|
||||
CREATE TABLE custom_fields (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
field_name TEXT NOT NULL,
|
||||
field_type TEXT NOT NULL DEFAULT 'text',
|
||||
field_options JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE custom_fields ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage own custom fields" ON custom_fields FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- CONTACT_CUSTOM_VALUES
|
||||
-- ============================================================
|
||||
CREATE TABLE contact_custom_values (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
custom_field_id UUID NOT NULL REFERENCES custom_fields(id) ON DELETE CASCADE,
|
||||
value TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(contact_id, custom_field_id)
|
||||
);
|
||||
|
||||
ALTER TABLE contact_custom_values ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage custom values" ON contact_custom_values FOR ALL
|
||||
USING (EXISTS (SELECT 1 FROM contacts WHERE contacts.id = contact_custom_values.contact_id AND contacts.user_id = auth.uid()));
|
||||
|
||||
-- ============================================================
|
||||
-- CONTACT_NOTES
|
||||
-- ============================================================
|
||||
CREATE TABLE contact_notes (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
note_text TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE contact_notes ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage own notes" ON contact_notes FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- CONVERSATIONS
|
||||
-- ============================================================
|
||||
CREATE TABLE conversations (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'pending', 'closed')),
|
||||
assigned_agent_id UUID,
|
||||
last_message_text TEXT,
|
||||
last_message_at TIMESTAMPTZ,
|
||||
unread_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_conversations_user_id ON conversations(user_id);
|
||||
CREATE INDEX idx_conversations_contact_id ON conversations(contact_id);
|
||||
|
||||
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage own conversations" ON conversations FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- MESSAGES
|
||||
-- ============================================================
|
||||
CREATE TABLE messages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
sender_type TEXT NOT NULL CHECK (sender_type IN ('customer', 'agent', 'bot')),
|
||||
sender_id UUID,
|
||||
content_type TEXT NOT NULL DEFAULT 'text' CHECK (content_type IN ('text', 'image', 'document', 'audio', 'video', 'location', 'template')),
|
||||
content_text TEXT,
|
||||
media_url TEXT,
|
||||
template_name TEXT,
|
||||
message_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'sent' CHECK (status IN ('sending', 'sent', 'delivered', 'read', 'failed')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_messages_conversation ON messages(conversation_id);
|
||||
CREATE INDEX idx_messages_message_id ON messages(message_id);
|
||||
|
||||
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can view own messages" ON messages FOR ALL
|
||||
USING (EXISTS (SELECT 1 FROM conversations WHERE conversations.id = messages.conversation_id AND conversations.user_id = auth.uid()));
|
||||
|
||||
-- Service role bypass for webhook inserts
|
||||
CREATE POLICY "Service role can insert messages" ON messages FOR INSERT
|
||||
WITH CHECK (true);
|
||||
|
||||
-- ============================================================
|
||||
-- WHATSAPP_CONFIG
|
||||
-- ============================================================
|
||||
CREATE TABLE whatsapp_config (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
phone_number_id TEXT NOT NULL,
|
||||
waba_id TEXT,
|
||||
access_token TEXT NOT NULL,
|
||||
verify_token TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'disconnected' CHECK (status IN ('connected', 'disconnected')),
|
||||
connected_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id)
|
||||
);
|
||||
|
||||
ALTER TABLE whatsapp_config ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage own config" ON whatsapp_config FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- MESSAGE_TEMPLATES
|
||||
-- ============================================================
|
||||
CREATE TABLE message_templates (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'Marketing' CHECK (category IN ('Marketing', 'Utility', 'Authentication')),
|
||||
language TEXT DEFAULT 'en_US',
|
||||
header_type TEXT CHECK (header_type IN ('text', 'image', 'video', 'document')),
|
||||
header_content TEXT,
|
||||
body_text TEXT NOT NULL,
|
||||
footer_text TEXT,
|
||||
buttons JSONB,
|
||||
status TEXT DEFAULT 'Draft' CHECK (status IN ('Draft', 'Pending', 'Approved', 'Rejected')),
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE message_templates ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage own templates" ON message_templates FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- PIPELINES
|
||||
-- ============================================================
|
||||
CREATE TABLE pipelines (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE pipelines ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage own pipelines" ON pipelines FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- PIPELINE_STAGES
|
||||
-- ============================================================
|
||||
CREATE TABLE pipeline_stages (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
pipeline_id UUID NOT NULL REFERENCES pipelines(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_pipeline_stages_pipeline ON pipeline_stages(pipeline_id);
|
||||
|
||||
ALTER TABLE pipeline_stages ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage pipeline stages" ON pipeline_stages FOR ALL
|
||||
USING (EXISTS (SELECT 1 FROM pipelines WHERE pipelines.id = pipeline_stages.pipeline_id AND pipelines.user_id = auth.uid()));
|
||||
|
||||
-- ============================================================
|
||||
-- DEALS
|
||||
-- ============================================================
|
||||
CREATE TABLE deals (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
pipeline_id UUID NOT NULL REFERENCES pipelines(id) ON DELETE CASCADE,
|
||||
stage_id UUID NOT NULL REFERENCES pipeline_stages(id),
|
||||
contact_id UUID NOT NULL REFERENCES contacts(id),
|
||||
conversation_id UUID REFERENCES conversations(id),
|
||||
title TEXT NOT NULL,
|
||||
value NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||
currency TEXT DEFAULT 'USD',
|
||||
notes TEXT,
|
||||
expected_close_date DATE,
|
||||
status TEXT DEFAULT 'active',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_deals_pipeline ON deals(pipeline_id);
|
||||
CREATE INDEX idx_deals_stage ON deals(stage_id);
|
||||
|
||||
ALTER TABLE deals ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage own deals" ON deals FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- BROADCASTS
|
||||
-- ============================================================
|
||||
CREATE TABLE broadcasts (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
template_name TEXT NOT NULL,
|
||||
template_language TEXT NOT NULL DEFAULT 'en_US',
|
||||
template_variables JSONB,
|
||||
audience_filter JSONB,
|
||||
scheduled_at TIMESTAMPTZ,
|
||||
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'failed')),
|
||||
total_recipients INTEGER DEFAULT 0,
|
||||
sent_count INTEGER DEFAULT 0,
|
||||
delivered_count INTEGER DEFAULT 0,
|
||||
read_count INTEGER DEFAULT 0,
|
||||
replied_count INTEGER DEFAULT 0,
|
||||
failed_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
ALTER TABLE broadcasts ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage own broadcasts" ON broadcasts FOR ALL USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================
|
||||
-- BROADCAST_RECIPIENTS
|
||||
-- ============================================================
|
||||
CREATE TABLE broadcast_recipients (
|
||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
broadcast_id UUID NOT NULL REFERENCES broadcasts(id) ON DELETE CASCADE,
|
||||
contact_id UUID NOT NULL REFERENCES contacts(id),
|
||||
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'delivered', 'read', 'replied', 'failed')),
|
||||
sent_at TIMESTAMPTZ,
|
||||
delivered_at TIMESTAMPTZ,
|
||||
read_at TIMESTAMPTZ,
|
||||
replied_at TIMESTAMPTZ,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_broadcast_recipients_broadcast ON broadcast_recipients(broadcast_id);
|
||||
|
||||
ALTER TABLE broadcast_recipients ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Users can manage broadcast recipients" ON broadcast_recipients FOR ALL
|
||||
USING (EXISTS (SELECT 1 FROM broadcasts WHERE broadcasts.id = broadcast_recipients.broadcast_id AND broadcasts.user_id = auth.uid()));
|
||||
|
||||
-- ============================================================
|
||||
-- UPDATED_AT TRIGGER FUNCTION
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Apply to tables with updated_at
|
||||
CREATE TRIGGER set_updated_at BEFORE UPDATE ON profiles FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER set_updated_at BEFORE UPDATE ON contacts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER set_updated_at BEFORE UPDATE ON conversations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER set_updated_at BEFORE UPDATE ON whatsapp_config FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER set_updated_at BEFORE UPDATE ON message_templates FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER set_updated_at BEFORE UPDATE ON deals FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
CREATE TRIGGER set_updated_at BEFORE UPDATE ON broadcasts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
-- ============================================================
|
||||
-- AUTO-CREATE PROFILE ON USER SIGNUP
|
||||
-- ============================================================
|
||||
CREATE OR REPLACE FUNCTION handle_new_user()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
INSERT INTO profiles (user_id, full_name, email)
|
||||
VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'full_name', ''), NEW.email);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
|
||||
|
||||
-- ============================================================
|
||||
-- ENABLE REALTIME for key tables
|
||||
-- ============================================================
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE conversations;
|
||||
Loading…
Reference in New Issue
Block a user