diff --git a/package-lock.json b/package-lock.json index 14afdf6..93f64b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 7211ce7..910d9cf 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/app/(auth)/forgot-password/page.tsx b/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..f499478 --- /dev/null +++ b/src/app/(auth)/forgot-password/page.tsx @@ -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(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 ( +
+ + +
+ +
+ + Check your email + + + We've sent a password reset link to{" "} + {email}. Please check your + inbox. + +
+ + + + + +
+
+ ); + } + + return ( +
+ + +
+ +
+ Reset password + + Enter your email and we'll send you a reset link + +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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" + /> +
+ + +
+ + + + Back to sign in + +
+
+
+ ); +} diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..5f0f788 --- /dev/null +++ b/src/app/(auth)/login/page.tsx @@ -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(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 ( +
+ + +
+ +
+ Welcome back + + Sign in to your WaCRM account + +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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" + /> +
+ +
+
+ + + Forgot password? + +
+ 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" + /> +
+ + +
+ +

+ Don't have an account?{" "} + + Create account + +

+
+
+
+ ); +} diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..474572d --- /dev/null +++ b/src/app/(auth)/signup/page.tsx @@ -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(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 ( +
+ + +
+ +
+ + Check your email + + + We've sent a confirmation link to{" "} + {email}. Please check your + inbox and click the link to verify your account. + +
+ + + + + +
+
+ ); + } + + return ( +
+ + +
+ +
+ Create account + + Get started with WaCRM + +
+ +
+ {error && ( +
+ {error} +
+ )} + +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ +
+ + 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" + /> +
+ + +
+ +

+ Already have an account?{" "} + + Sign in + +

+
+
+
+ ); +} diff --git a/src/app/(dashboard)/automations/page.tsx b/src/app/(dashboard)/automations/page.tsx new file mode 100644 index 0000000..c4ba314 --- /dev/null +++ b/src/app/(dashboard)/automations/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { Zap } from "lucide-react"; + +export default function AutomationsPage() { + return ( +
+
+

Automations

+

+ Automate your WhatsApp messaging workflows +

+
+ +
+
+ +
+

Coming Soon

+

+ Set up automated responses, workflows, and triggers for your WhatsApp + messages. +

+
+

+ This feature is under development +

+
+
+
+ ); +} diff --git a/src/app/(dashboard)/broadcasts/[id]/page.tsx b/src/app/(dashboard)/broadcasts/[id]/page.tsx new file mode 100644 index 0000000..a11db15 --- /dev/null +++ b/src/app/(dashboard)/broadcasts/[id]/page.tsx @@ -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 = { + 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 = { + 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 ( +
+
+
+ {icon} +
+ {pct}% +
+

{value.toLocaleString()}

+

{label}

+
+ ); +} + +export default function BroadcastDetailPage() { + const params = useParams(); + const router = useRouter(); + const broadcastId = params.id as string; + + const [broadcast, setBroadcast] = useState(null); + const [recipients, setRecipients] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +
+ ); + } + + if (error || !broadcast) { + return ( +
+

{error ?? 'Broadcast not found'}

+ +
+ ); + } + + const status = statusConfig[broadcast.status] ?? statusConfig.draft; + + return ( +
+ {/* Header */} +
+
+ +
+
+

{broadcast.name}

+ + {status.label} + +
+
+ Template: {broadcast.template_name} + - + Created {new Date(broadcast.created_at).toLocaleDateString()} +
+
+
+
+ + {/* Stats */} +
+ } + color="bg-slate-800 text-slate-300" + /> + } + color="bg-blue-500/10 text-blue-400" + /> + } + color="bg-emerald-500/10 text-emerald-400" + /> + } + color="bg-emerald-500/10 text-emerald-300" + /> + } + color="bg-red-500/10 text-red-400" + /> +
+ + {/* Recipients Table */} +
+
+

+ Recipients ({recipients.length}) +

+
+ {recipients.length === 0 ? ( +
+

No recipients found.

+
+ ) : ( + + + + Contact + Phone + Status + Sent At + Error + + + + {recipients.map((recipient) => { + const rStatus = + recipientStatusConfig[recipient.status] ?? recipientStatusConfig.pending; + + return ( + + + {recipient.contact?.name ?? 'Unknown'} + + + {recipient.contact?.phone ?? '-'} + + + + {rStatus.label} + + + + {recipient.sent_at + ? new Date(recipient.sent_at).toLocaleString() + : '-'} + + + {recipient.error_message ?? '-'} + + + ); + })} + +
+ )} +
+
+ ); +} diff --git a/src/app/(dashboard)/broadcasts/new/page.tsx b/src/app/(dashboard)/broadcasts/new/page.tsx new file mode 100644 index 0000000..ec8e0e5 --- /dev/null +++ b/src/app/(dashboard)/broadcasts/new/page.tsx @@ -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(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 + >({}); + 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 ( +
+ {/* Header */} +
+

New Broadcast

+

+ Create and send a broadcast message to your contacts. +

+
+ + {/* Step Indicator */} +
+ {steps.map((step, index) => { + const isActive = index === currentStep; + const isCompleted = index < currentStep; + + return ( +
+
+
+ {isCompleted ? : index + 1} +
+ + {step.label} + +
+ {index < steps.length - 1 && ( +
+ )} +
+ ); + })} +
+ + {/* Step Content */} +
+
+ {currentStep === 0 && ( + setCurrentStep(1)} + onBack={() => router.push('/broadcasts')} + /> + )} + {currentStep === 1 && ( + setCurrentStep(2)} + onBack={() => setCurrentStep(0)} + /> + )} + {currentStep === 2 && template && ( + setCurrentStep(3)} + onBack={() => setCurrentStep(1)} + /> + )} + {currentStep === 3 && template && ( + setCurrentStep(2)} + isProcessing={isProcessing} + progress={progress} + /> + )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/broadcasts/page.tsx b/src/app/(dashboard)/broadcasts/page.tsx new file mode 100644 index 0000000..ba48c51 --- /dev/null +++ b/src/app/(dashboard)/broadcasts/page.tsx @@ -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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+

{error}

+ +
+ ); + } + + return ( +
+
+
+

Broadcasts

+

+ Send bulk messages to your contacts using approved templates. +

+
+ +
+ + {broadcasts.length === 0 ? ( +
+ +

No broadcasts yet

+

+ Create your first broadcast to reach your contacts at scale. +

+ +
+ ) : ( +
+ + + + Name + Template + Status + Recipients + Sent + Delivered + Read + Failed + Created + + + + {broadcasts.map((broadcast) => { + const status = statusConfig[broadcast.status] ?? statusConfig.draft; + return ( + router.push(`/broadcasts/${broadcast.id}`)} + > + + {broadcast.name} + + + {broadcast.template_name} + + + + {status.label} + + + + {broadcast.total_recipients} + + + {broadcast.sent_count} + + + {broadcast.delivered_count} + + + {broadcast.read_count} + + + {broadcast.failed_count} + + + {new Date(broadcast.created_at).toLocaleDateString()} + + + ); + })} + +
+
+ )} +
+ ); +} diff --git a/src/app/(dashboard)/contacts/page.tsx b/src/app/(dashboard)/contacts/page.tsx new file mode 100644 index 0000000..7f0d4eb --- /dev/null +++ b/src/app/(dashboard)/contacts/page.tsx @@ -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([]); + 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(null); + const [editContactTags, setEditContactTags] = useState([]); + const [detailOpen, setDetailOpen] = useState(false); + const [detailContactId, setDetailContactId] = useState(null); + const [importOpen, setImportOpen] = useState(false); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + + // All tags for display + const [tagsMap, setTagsMap] = useState>({}); + + const fetchTags = useCallback(async () => { + const { data } = await supabase.from('tags').select('*'); + if (data) { + const map: Record = {}; + 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 = {}; + 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 ( +
+ {/* Header */} +
+
+

Contacts

+

+ Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`} +

+
+
+ + +
+
+ + {/* Search */} +
+ + 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" + /> +
+ + {/* Table */} +
+ + + + Name + Phone + Email + Company + Tags + Created + + + + + {loading ? ( + + +
+ +

Loading contacts...

+
+
+
+ ) : contacts.length === 0 ? ( + + +
+ +

+ {search ? 'No contacts match your search.' : 'No contacts yet.'} +

+ {!search && ( + + )} +
+
+
+ ) : ( + contacts.map((contact) => ( + openDetail(contact.id)} + > + + {contact.name || Unnamed} + + + {contact.phone} + + + {contact.email || -} + + + {contact.company || -} + + +
+ {contact.tags && contact.tags.length > 0 ? ( + contact.tags.slice(0, 3).map((tag) => ( + + {tag.name} + + )) + ) : ( + - + )} + {contact.tags && contact.tags.length > 3 && ( + + +{contact.tags.length - 3} + + )} +
+
+ + {new Date(contact.created_at).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + + e.stopPropagation()} + /> + } + > + + + + { + e.stopPropagation(); + openEditForm(contact); + }} + className="text-slate-300 focus:bg-slate-800 focus:text-white" + > + + Edit + + + { + e.stopPropagation(); + confirmDelete(contact); + }} + > + + Delete + + + + +
+ )) + )} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Showing {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalCount)} of{' '} + {totalCount} +

+
+ + + Page {page + 1} of {totalPages} + + +
+
+ )} + + {/* Contact Form Dialog */} + { + fetchContacts(); + fetchTags(); + }} + /> + + {/* Contact Detail Sheet */} + + + {/* Import Modal */} + + + {/* Delete Confirmation */} + + + + Delete Contact + + Are you sure you want to delete{' '} + + {deleteTarget?.name || deleteTarget?.phone} + + ? This action cannot be undone. + + + + + + + + +
+ ); +} diff --git a/src/app/(dashboard)/dashboard/page.tsx b/src/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..bcfb23e --- /dev/null +++ b/src/app/(dashboard)/dashboard/page.tsx @@ -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 ( +
+
+

{title}

+
+ {icon} +
+
+ {loading ? ( +
+ ) : ( +

{value}

+ )} +
+ ); +} + +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([]); + const [pipelineOverviews, setPipelineOverviews] = useState([]); + const [recentBroadcasts, setRecentBroadcasts] = useState([]); + + 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 ( +
+ {/* Header */} +
+

Dashboard

+

+ Overview of your WhatsApp CRM activity +

+
+ + {/* Row 1: Stat Cards */} +
+ } + loading={loading} + /> + } + loading={loading} + /> + } + loading={loading} + /> + } + loading={loading} + /> +
+ + {/* Row 2: Recent Conversations */} +
+
+
+ +

+ Recent Conversations +

+
+ +
+
+ {loading ? ( + Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ )) + ) : recentConversations.length === 0 ? ( +
+ No conversations yet +
+ ) : ( + recentConversations.map((conv) => ( + + )) + )} +
+
+ + {/* Row 3: Pipeline Overview */} +
+
+ +

+ Pipeline Overview +

+
+
+ {loading ? ( +
+ {[1, 2].map((i) => ( +
+
+
+ {[1, 2, 3, 4].map((j) => ( +
+ ))} +
+
+ ))} +
+ ) : pipelineOverviews.length === 0 ? ( +
+ No pipelines created yet +
+ ) : ( +
+ {pipelineOverviews.map(({ pipeline, stages }) => ( +
+

+ {pipeline.name} +

+
+ {stages.map((stage) => ( +
+
+
+ + {stage.name} + +
+

+ {stage.dealCount} +

+ {stage.totalValue > 0 && ( +

+ + {formatCurrency(stage.totalValue)} +

+ )} +
+ ))} +
+
+ ))} +
+ )} +
+
+ + {/* Row 4: Recent Broadcasts */} +
+
+ +

+ Recent Broadcasts +

+
+
+ {loading ? ( + Array.from({ length: 2 }).map((_, i) => ( +
+
+
+
+
+
+
+ )) + ) : recentBroadcasts.length === 0 ? ( +
+ No broadcasts yet +
+ ) : ( + recentBroadcasts.map((broadcast) => ( +
+
+ +
+
+

+ {broadcast.name} +

+
+ + {broadcast.sent_count}/{broadcast.total_recipients} sent + + + + {formatRelativeTime(broadcast.created_at)} + +
+
+ + {broadcast.status} + +
+ )) + )} +
+
+
+ ); +} diff --git a/src/app/(dashboard)/inbox/page.tsx b/src/app/(dashboard)/inbox/page.tsx new file mode 100644 index 0000000..a826b11 --- /dev/null +++ b/src/app/(dashboard)/inbox/page.tsx @@ -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([]); + const [activeConversation, setActiveConversation] = + useState(null); + const [activeContact, setActiveContact] = useState(null); + const [messages, setMessages] = useState([]); + const [whatsappConnected, setWhatsappConnected] = useState( + 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 }) => { + 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; + }) => { + 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 ( +
+ {/* WhatsApp connection banner */} + {whatsappConnected === false && ( +
+ +

+ WhatsApp is not connected. Go to Settings to connect your account. +

+
+ )} + + {/* Left panel: Conversation list */} + + + {/* Center panel: Message thread */} + + + {/* Right panel: Contact sidebar (hidden on small screens) */} +
+ +
+
+ ); +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..a7d0ee4 --- /dev/null +++ b/src/app/(dashboard)/layout.tsx @@ -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 ( +
+
+
+

Loading...

+
+
+ ); + } + + if (!user) { + return null; + } + + return ( +
+ +
+
+
{children}
+
+
+ ); +} diff --git a/src/app/(dashboard)/pipelines/page.tsx b/src/app/(dashboard)/pipelines/page.tsx new file mode 100644 index 0000000..ba2d69c --- /dev/null +++ b/src/app/(dashboard)/pipelines/page.tsx @@ -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([]); + const [stages, setStages] = useState([]); + const [selectedPipelineId, setSelectedPipelineId] = useState(""); + 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 ( +
+
+
+
+
+
+
+
+
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

Pipelines

+

+ Manage your deals and sales pipeline +

+
+
+ {selectedPipeline && ( + + )} + +
+
+ + {/* Pipeline selector */} + {pipelines.length > 1 && ( +
+ + {dropdownOpen && ( + <> +
setDropdownOpen(false)} + /> +
+ {pipelines.map((p) => ( + + ))} +
+ + )} +
+ )} + + {/* Board or empty state */} + {pipelines.length === 0 ? ( +
+ +

+ No pipelines yet +

+

+ Create your first pipeline to start tracking deals +

+ +
+ ) : ( + loadStages(selectedPipelineId)} + /> + )} + + {/* New Pipeline Dialog */} + + + + New Pipeline + +
+ + 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(); + }} + /> +

+ Default stages (Lead, Qualified, Proposal, Negotiation, Won) will + be created automatically. +

+
+ + + + +
+
+ + {/* Pipeline Settings */} + {selectedPipeline && ( + + )} +
+ ); +} diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx new file mode 100644 index 0000000..e9ed10e --- /dev/null +++ b/src/app/(dashboard)/settings/page.tsx @@ -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 ( +
+
+

Settings

+

+ Manage your WhatsApp integration, message templates, and tags. +

+
+ + + + + + WhatsApp Config + + + + Templates + + + + Tags + + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/app/api/whatsapp/broadcast/route.ts b/src/app/api/whatsapp/broadcast/route.ts new file mode 100644 index 0000000..3091d12 --- /dev/null +++ b/src/app/api/whatsapp/broadcast/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/whatsapp/config/route.ts b/src/app/api/whatsapp/config/route.ts new file mode 100644 index 0000000..7fb39a6 --- /dev/null +++ b/src/app/api/whatsapp/config/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/whatsapp/media/[mediaId]/route.ts b/src/app/api/whatsapp/media/[mediaId]/route.ts new file mode 100644 index 0000000..3bfe46d --- /dev/null +++ b/src/app/api/whatsapp/media/[mediaId]/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts new file mode 100644 index 0000000..7823280 --- /dev/null +++ b/src/app/api/whatsapp/send/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts new file mode 100644 index 0000000..55c1562 --- /dev/null +++ b/src/app/api/whatsapp/webhook/route.ts @@ -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 +} diff --git a/src/app/globals.css b/src/app/globals.css index c56032b..01ec949 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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 { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 976eb90..d77e8c9 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - - {children} + + + {children} + + ); } diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f36f7c..9f85280 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,65 +1,5 @@ -import Image from "next/image"; +import { redirect } from "next/navigation"; export default function Home() { - return ( -
-
- Next.js logo -
-

- To get started, edit the page.tsx file. -

-

- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. -

-
- -
-
- ); + redirect("/login"); } diff --git a/src/components/broadcasts/step1-choose-template.tsx b/src/components/broadcasts/step1-choose-template.tsx new file mode 100644 index 0000000..c8ec255 --- /dev/null +++ b/src/components/broadcasts/step1-choose-template.tsx @@ -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 = { + 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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ +
+ ); + } + + if (error) { + return ( +
+

{error}

+
+ ); + } + + return ( +
+
+

Choose a Template

+

+ Select an approved message template for your broadcast. +

+
+ + {templates.length === 0 ? ( +
+ +

No templates available.

+

Create a template in Settings first.

+
+ ) : ( +
+ {templates.map((template) => { + const isSelected = selectedTemplate?.id === template.id; + const catColor = categoryColors[template.category] ?? categoryColors.Utility; + + return ( + + ); + })} +
+ )} + +
+ + +
+
+ ); +} diff --git a/src/components/broadcasts/step2-select-audience.tsx b/src/components/broadcasts/step2-select-audience.tsx new file mode 100644 index 0000000..2e75f98 --- /dev/null +++ b/src/components/broadcasts/step2-select-audience.tsx @@ -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([]); + const [loadingTags, setLoadingTags] = useState(false); + const [estimatedCount, setEstimatedCount] = useState(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 ( +
+
+

Select Audience

+

Choose who will receive this broadcast.

+
+ +
+ {audienceOptions.map((option) => { + const isSelected = audience.type === option.type; + const Icon = option.icon; + return ( + + ); + })} +
+ + {audience.type === 'tags' && ( +
+

Select Tags

+ {loadingTags ? ( + + ) : tags.length === 0 ? ( +

No tags found. Create tags in Settings.

+ ) : ( +
+ {tags.map((tag) => { + const isSelected = audience.tagIds?.includes(tag.id); + return ( + + ); + })} +
+ )} +
+ )} + + {/* Audience Summary */} +
+

Audience Summary

+ {loadingCount ? ( +
+ + Calculating... +
+ ) : estimatedCount !== null ? ( +
+ + {estimatedCount.toLocaleString()} + estimated recipients +
+ ) : ( +

Select an audience type to see the estimate.

+ )} +
+ +
+ + +
+
+ ); +} diff --git a/src/components/broadcasts/step3-personalize.tsx b/src/components/broadcasts/step3-personalize.tsx new file mode 100644 index 0000000..fa3764a --- /dev/null +++ b/src/components/broadcasts/step3-personalize.tsx @@ -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; + onUpdate: (variables: Record) => 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) { + 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 ( +
+
+

Personalize Message

+

+ Map template variables to contact fields or static values. +

+
+ + {placeholders.length === 0 ? ( +
+

This template has no variables to personalize.

+
+ ) : ( +
+ {placeholders.map((placeholder) => { + const mapping = variables[placeholder] ?? { type: 'static', value: '' }; + + return ( +
+
+ + {placeholder} + +
+ +
+
+ + +
+ +
+ + {mapping.type === 'static' ? ( + updateVariable(placeholder, { value: e.target.value })} + placeholder="Enter value..." + className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500" + /> + ) : ( + + )} +
+
+
+ ); + })} +
+ )} + + {/* Preview */} +
+
+ +

Preview

+ (with sample data) +
+
+

{previewText}

+
+
+ +
+ + +
+
+ ); +} diff --git a/src/components/broadcasts/step4-schedule-send.tsx b/src/components/broadcasts/step4-schedule-send.tsx new file mode 100644 index 0000000..dfaf3dd --- /dev/null +++ b/src/components/broadcasts/step4-schedule-send.tsx @@ -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(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 ( +
+
+

Review & Send

+

+ Name your broadcast, review the details, and send. +

+
+ + {/* Broadcast Name */} +
+ + onNameChange(e.target.value)} + placeholder="e.g. Summer Sale Announcement" + className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500" + /> +
+ + {/* Summary Card */} +
+

Summary

+
+
+

Template

+

{template.name}

+
+
+

Audience

+

{audienceLabel}

+
+
+

Estimated Reach

+
+ {loadingReach ? ( + + ) : ( + <> + +

{estimatedReach.toLocaleString()}

+ + )} +
+
+
+

Language

+

{template.language ?? 'en_US'}

+
+
+
+ + {/* Delivery Timing */} +
+

Delivery Timing

+
+ + +
+ +
+
+

Schedule for Later

+ + + Pro + +
+

Pick a date and time

+
+
+
+
+ + {/* Processing overlay */} + {isProcessing && ( +
+
+
+ +

Sending broadcast...

+
+ {progress}% +
+
+
+
+
+ )} + +
+ + + + + } + > + + Send Broadcast + + + + Confirm Broadcast + + You are about to send this broadcast to{' '} + {estimatedReach.toLocaleString()}{' '} + contacts using the{' '} + {template.name} template. + This action cannot be undone. + + + + + + + + +
+
+ ); +} diff --git a/src/components/contacts/contact-detail-view.tsx b/src/components/contacts/contact-detail-view.tsx new file mode 100644 index 0000000..382a27d --- /dev/null +++ b/src/components/contacts/contact-detail-view.tsx @@ -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(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([]); + const [contactTagIds, setContactTagIds] = useState([]); + const [savingTags, setSavingTags] = useState(false); + + // Notes tab + const [notes, setNotes] = useState([]); + const [newNote, setNewNote] = useState(''); + const [savingNote, setSavingNote] = useState(false); + const [loadingNotes, setLoadingNotes] = useState(false); + + // Custom fields tab + const [customFields, setCustomFields] = useState([]); + const [customValues, setCustomValues] = useState>({}); + 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 = {}; + 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 ( + + + {loading || !contact ? ( +
+ +
+ ) : ( +
+ {/* Header */} + +
+ + + {getInitials(contact.name)} + + +
+ + {contact.name || 'Unknown'} + + + Contact details + +
+ + {contact.email && ( + + + {contact.email} + + )} + {contact.company && ( + + + {contact.company} + + )} +
+
+
+
+ + {/* Tabs */} + + + + Details + + + Tags + + + Notes + + + Custom Fields + + + + {/* Details Tab */} + +
+
+ + setEditName(e.target.value)} + className="bg-slate-800 border-slate-700 text-white h-8 text-sm" + /> +
+
+ + setEditPhone(e.target.value)} + className="bg-slate-800 border-slate-700 text-white h-8 text-sm" + /> +
+
+ + setEditEmail(e.target.value)} + className="bg-slate-800 border-slate-700 text-white h-8 text-sm" + /> +
+
+ + setEditCompany(e.target.value)} + className="bg-slate-800 border-slate-700 text-white h-8 text-sm" + /> +
+ +
+
+ + {/* Tags Tab */} + +
+

+ Click a tag to add or remove it from this contact. +

+ {allTags.length === 0 ? ( +

+ No tags available. Create tags in Settings. +

+ ) : ( +
+ {allTags.map((tag) => { + const selected = contactTagIds.includes(tag.id); + return ( + + ); + })} +
+ )} +
+
+ + {/* Notes Tab */} + +
+