mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Initial scaffold: WhatsApp CRM with Next.js + Supabase
Complete WhatsApp CRM application built with Next.js 14 (App Router), Supabase (PostgreSQL + Auth + Realtime), and shadcn/ui. Features: - Auth: Login, signup, forgot password with Supabase Auth - Dashboard: Analytics overview with stats, recent activity - Inbox: Real-time WhatsApp conversations with message threading - Contacts: CRUD with tags, custom fields, notes, CSV import - Broadcasts: 4-step wizard with audience selection, template sending - Pipelines: Kanban board with deal management - Settings: WhatsApp API config, templates, tags - WhatsApp API: Webhook (verify + receive), send, broadcast, media proxy - Database: 16 tables with RLS policies, indexes, realtime subscriptions - Dark theme throughout with emerald accents Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
de3deb32da
commit
658d070811
167
package-lock.json
generated
167
package-lock.json
generated
@ -9,13 +9,17 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
|
"@supabase/ssr": "^0.10.2",
|
||||||
|
"@supabase/supabase-js": "^2.103.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"shadcn": "^4.2.0",
|
"shadcn": "^4.2.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
@ -1956,6 +1960,117 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@ -2347,7 +2462,6 @@
|
|||||||
"version": "20.19.39",
|
"version": "20.19.39",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@ -2385,6 +2499,15 @@
|
|||||||
"integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==",
|
"integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.58.2",
|
"version": "8.58.2",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.2.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
@ -5593,6 +5715,15 @@
|
|||||||
"node": ">=18.18.0"
|
"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": {
|
"node_modules/iconv-lite": {
|
||||||
"version": "0.7.2",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
@ -8555,6 +8686,16 @@
|
|||||||
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
@ -9265,7 +9406,6 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unicorn-magic": {
|
"node_modules/unicorn-magic": {
|
||||||
@ -9599,6 +9739,27 @@
|
|||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/wsl-utils": {
|
||||||
"version": "0.3.1",
|
"version": "0.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
||||||
|
|||||||
@ -10,13 +10,17 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
|
"@supabase/ssr": "^0.10.2",
|
||||||
|
"@supabase/supabase-js": "^2.103.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
"shadcn": "^4.2.0",
|
"shadcn": "^4.2.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tw-animate-css": "^1.4.0"
|
"tw-animate-css": "^1.4.0"
|
||||||
},
|
},
|
||||||
|
|||||||
131
src/app/(auth)/forgot-password/page.tsx
Normal file
131
src/app/(auth)/forgot-password/page.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { MessageSquare, CheckCircle, ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const handleReset = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||||
|
redirectTo: `${window.location.origin}/auth/callback?next=/reset-password`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||||
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||||
|
<CheckCircle className="h-6 w-6 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl text-white">
|
||||||
|
Check your email
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
We've sent a password reset link to{" "}
|
||||||
|
<span className="text-white">{email}</span>. Please check your
|
||||||
|
inbox.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||||
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||||
|
<MessageSquare className="h-6 w-6 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl text-white">Reset password</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
Enter your email and we'll send you a reset link
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleReset} className="flex flex-col gap-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="email" className="text-slate-300">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-emerald-500 focus-visible:ring-emerald-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-2 h-10 w-full bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Sending..." : "Send reset link"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="mt-6 flex items-center justify-center gap-2 text-sm text-slate-400 hover:text-slate-300"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to sign in
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
126
src/app/(auth)/login/page.tsx
Normal file
126
src/app/(auth)/login/page.tsx
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { MessageSquare } from "lucide-react";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const handleLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/dashboard");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||||
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||||
|
<MessageSquare className="h-6 w-6 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl text-white">Welcome back</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
Sign in to your WaCRM account
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="email" className="text-slate-300">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-emerald-500 focus-visible:ring-emerald-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="password" className="text-slate-300">
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<Link
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-sm text-emerald-500 hover:text-emerald-400"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter your password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-emerald-500 focus-visible:ring-emerald-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-2 h-10 w-full bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-slate-400">
|
||||||
|
Don't have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/signup"
|
||||||
|
className="text-emerald-500 hover:text-emerald-400"
|
||||||
|
>
|
||||||
|
Create account
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
198
src/app/(auth)/signup/page.tsx
Normal file
198
src/app/(auth)/signup/page.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { MessageSquare, CheckCircle } from "lucide-react";
|
||||||
|
|
||||||
|
export default function SignupPage() {
|
||||||
|
const [fullName, setFullName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const handleSignup = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
setError("Passwords do not match");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
setError("Password must be at least 6 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const { error } = await supabase.auth.signUp({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
options: {
|
||||||
|
data: {
|
||||||
|
full_name: fullName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
setError(error.message);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSuccess(true);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||||
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||||
|
<CheckCircle className="h-6 w-6 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl text-white">
|
||||||
|
Check your email
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
We've sent a confirmation link to{" "}
|
||||||
|
<span className="text-white">{email}</span>. Please check your
|
||||||
|
inbox and click the link to verify your account.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Link href="/login">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full border-slate-700 text-slate-300 hover:bg-slate-800 hover:text-white"
|
||||||
|
>
|
||||||
|
Back to sign in
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-slate-950 px-4">
|
||||||
|
<Card className="w-full max-w-md border-slate-800 bg-slate-900">
|
||||||
|
<CardHeader className="items-center text-center">
|
||||||
|
<div className="mb-2 flex h-12 w-12 items-center justify-center rounded-xl bg-emerald-500/10">
|
||||||
|
<MessageSquare className="h-6 w-6 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<CardTitle className="text-xl text-white">Create account</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
Get started with WaCRM
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<form onSubmit={handleSignup} className="flex flex-col gap-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="fullName" className="text-slate-300">
|
||||||
|
Full name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="fullName"
|
||||||
|
type="text"
|
||||||
|
placeholder="John Doe"
|
||||||
|
value={fullName}
|
||||||
|
onChange={(e) => setFullName(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-emerald-500 focus-visible:ring-emerald-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="email" className="text-slate-300">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-emerald-500 focus-visible:ring-emerald-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="password" className="text-slate-300">
|
||||||
|
Password
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="At least 6 characters"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-emerald-500 focus-visible:ring-emerald-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="confirmPassword" className="text-slate-300">
|
||||||
|
Confirm password
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
placeholder="Repeat your password"
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500 focus-visible:border-emerald-500 focus-visible:ring-emerald-500/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="mt-2 h-10 w-full bg-emerald-600 text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? "Creating account..." : "Create account"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-slate-400">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="text-emerald-500 hover:text-emerald-400"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/app/(dashboard)/automations/page.tsx
Normal file
32
src/app/(dashboard)/automations/page.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Zap } from "lucide-react";
|
||||||
|
|
||||||
|
export default function AutomationsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Automations</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Automate your WhatsApp messaging workflows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-700 bg-slate-900/50 py-20">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-500/10">
|
||||||
|
<Zap className="h-8 w-8 text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-6 text-lg font-semibold text-white">Coming Soon</h3>
|
||||||
|
<p className="mt-2 max-w-sm text-center text-sm text-slate-400">
|
||||||
|
Set up automated responses, workflows, and triggers for your WhatsApp
|
||||||
|
messages.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 rounded-lg border border-slate-700 bg-slate-800/50 px-4 py-2.5">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
This feature is under development
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
src/app/(dashboard)/broadcasts/[id]/page.tsx
Normal file
259
src/app/(dashboard)/broadcasts/[id]/page.tsx
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { Broadcast, BroadcastRecipient } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Loader2,
|
||||||
|
Users,
|
||||||
|
Send,
|
||||||
|
CheckCheck,
|
||||||
|
Eye,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; classes: string }> = {
|
||||||
|
draft: { label: 'Draft', classes: 'bg-slate-500/10 text-slate-400 border-slate-500/20' },
|
||||||
|
scheduled: { label: 'Scheduled', classes: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
|
||||||
|
sending: { label: 'Sending', classes: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20' },
|
||||||
|
sent: { label: 'Sent', classes: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' },
|
||||||
|
failed: { label: 'Failed', classes: 'bg-red-500/10 text-red-400 border-red-500/20' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const recipientStatusConfig: Record<string, { label: string; classes: string }> = {
|
||||||
|
pending: { label: 'Pending', classes: 'bg-slate-500/10 text-slate-400 border-slate-500/20' },
|
||||||
|
sent: { label: 'Sent', classes: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
|
||||||
|
delivered: { label: 'Delivered', classes: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' },
|
||||||
|
read: { label: 'Read', classes: 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20' },
|
||||||
|
replied: { label: 'Replied', classes: 'bg-purple-500/10 text-purple-400 border-purple-500/20' },
|
||||||
|
failed: { label: 'Failed', classes: 'bg-red-500/10 text-red-400 border-red-500/20' },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
total: number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, total, icon, color }: StatCardProps) {
|
||||||
|
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className={`flex h-8 w-8 items-center justify-center rounded-lg ${color}`}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-500">{pct}%</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-2xl font-bold text-white">{value.toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-slate-400">{label}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BroadcastDetailPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const broadcastId = params.id as string;
|
||||||
|
|
||||||
|
const [broadcast, setBroadcast] = useState<Broadcast | null>(null);
|
||||||
|
const [recipients, setRecipients] = useState<BroadcastRecipient[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const { data: bc, error: bcError } = await supabase
|
||||||
|
.from('broadcasts')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', broadcastId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (bcError) throw bcError;
|
||||||
|
setBroadcast(bc);
|
||||||
|
|
||||||
|
const { data: recs, error: recsError } = await supabase
|
||||||
|
.from('broadcast_recipients')
|
||||||
|
.select('*, contact:contacts(*)')
|
||||||
|
.eq('broadcast_id', broadcastId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (recsError) throw recsError;
|
||||||
|
setRecipients(recs ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load broadcast');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, [broadcastId]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !broadcast) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||||
|
<p className="text-sm text-red-400">{error ?? 'Broadcast not found'}</p>
|
||||||
|
<Button variant="outline" onClick={() => router.push('/broadcasts')}>
|
||||||
|
Back to Broadcasts
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = statusConfig[broadcast.status] ?? statusConfig.draft;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => router.push('/broadcasts')}
|
||||||
|
className="border-slate-700"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-white">{broadcast.name}</h1>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
||||||
|
>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-sm text-slate-400">
|
||||||
|
<span>Template: {broadcast.template_name}</span>
|
||||||
|
<span>-</span>
|
||||||
|
<span>Created {new Date(broadcast.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
|
||||||
|
<StatCard
|
||||||
|
label="Total Recipients"
|
||||||
|
value={broadcast.total_recipients}
|
||||||
|
total={broadcast.total_recipients}
|
||||||
|
icon={<Users className="h-4 w-4" />}
|
||||||
|
color="bg-slate-800 text-slate-300"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Sent"
|
||||||
|
value={broadcast.sent_count}
|
||||||
|
total={broadcast.total_recipients}
|
||||||
|
icon={<Send className="h-4 w-4" />}
|
||||||
|
color="bg-blue-500/10 text-blue-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Delivered"
|
||||||
|
value={broadcast.delivered_count}
|
||||||
|
total={broadcast.total_recipients}
|
||||||
|
icon={<CheckCheck className="h-4 w-4" />}
|
||||||
|
color="bg-emerald-500/10 text-emerald-400"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Read"
|
||||||
|
value={broadcast.read_count}
|
||||||
|
total={broadcast.total_recipients}
|
||||||
|
icon={<Eye className="h-4 w-4" />}
|
||||||
|
color="bg-emerald-500/10 text-emerald-300"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Failed"
|
||||||
|
value={broadcast.failed_count}
|
||||||
|
total={broadcast.total_recipients}
|
||||||
|
icon={<AlertCircle className="h-4 w-4" />}
|
||||||
|
color="bg-red-500/10 text-red-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recipients Table */}
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||||
|
<div className="border-b border-slate-800 px-4 py-3">
|
||||||
|
<h2 className="text-sm font-medium text-white">
|
||||||
|
Recipients ({recipients.length})
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
{recipients.length === 0 ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<p className="text-sm text-slate-400">No recipients found.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||||
|
<TableHead className="text-slate-400">Contact</TableHead>
|
||||||
|
<TableHead className="text-slate-400">Phone</TableHead>
|
||||||
|
<TableHead className="text-slate-400">Status</TableHead>
|
||||||
|
<TableHead className="text-slate-400">Sent At</TableHead>
|
||||||
|
<TableHead className="text-slate-400">Error</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{recipients.map((recipient) => {
|
||||||
|
const rStatus =
|
||||||
|
recipientStatusConfig[recipient.status] ?? recipientStatusConfig.pending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={recipient.id} className="border-slate-800">
|
||||||
|
<TableCell className="font-medium text-white">
|
||||||
|
{recipient.contact?.name ?? 'Unknown'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-300">
|
||||||
|
{recipient.contact?.phone ?? '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${rStatus.classes}`}
|
||||||
|
>
|
||||||
|
{rStatus.label}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-400">
|
||||||
|
{recipient.sent_at
|
||||||
|
? new Date(recipient.sent_at).toLocaleString()
|
||||||
|
: '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="max-w-xs truncate text-xs text-red-400">
|
||||||
|
{recipient.error_message ?? '-'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
src/app/(dashboard)/broadcasts/new/page.tsx
Normal file
156
src/app/(dashboard)/broadcasts/new/page.tsx
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { MessageTemplate } from '@/types';
|
||||||
|
import { Step1ChooseTemplate } from '@/components/broadcasts/step1-choose-template';
|
||||||
|
import { Step2SelectAudience } from '@/components/broadcasts/step2-select-audience';
|
||||||
|
import { Step3Personalize } from '@/components/broadcasts/step3-personalize';
|
||||||
|
import { Step4ScheduleSend } from '@/components/broadcasts/step4-schedule-send';
|
||||||
|
import { useBroadcastSending } from '@/hooks/use-broadcast-sending';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ label: 'Template', key: 'template' },
|
||||||
|
{ label: 'Audience', key: 'audience' },
|
||||||
|
{ label: 'Personalize', key: 'personalize' },
|
||||||
|
{ label: 'Send', key: 'send' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function NewBroadcastPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { createAndSendBroadcast, isProcessing, progress } = useBroadcastSending();
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState(0);
|
||||||
|
const [template, setTemplate] = useState<MessageTemplate | null>(null);
|
||||||
|
const [audience, setAudience] = useState<{
|
||||||
|
type: 'all' | 'tags' | 'custom_field' | 'csv';
|
||||||
|
tagIds?: string[];
|
||||||
|
csvContacts?: { phone: string; name?: string }[];
|
||||||
|
}>({ type: 'all' });
|
||||||
|
const [variables, setVariables] = useState<
|
||||||
|
Record<string, { type: 'static' | 'field'; value: string }>
|
||||||
|
>({});
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
async function handleSend() {
|
||||||
|
if (!template) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const broadcastId = await createAndSendBroadcast({
|
||||||
|
name,
|
||||||
|
template,
|
||||||
|
audience: {
|
||||||
|
type: audience.type === 'custom_field' ? 'all' : audience.type,
|
||||||
|
tagIds: audience.tagIds,
|
||||||
|
csvContacts: audience.csvContacts,
|
||||||
|
},
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
router.push(`/broadcasts/${broadcastId}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Broadcast failed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-3xl space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">New Broadcast</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Create and send a broadcast message to your contacts.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Indicator */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{steps.map((step, index) => {
|
||||||
|
const isActive = index === currentStep;
|
||||||
|
const isCompleted = index < currentStep;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={step.key} className="flex flex-1 items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium transition-all ${
|
||||||
|
isCompleted
|
||||||
|
? 'bg-emerald-500 text-white'
|
||||||
|
: isActive
|
||||||
|
? 'border-2 border-emerald-500 bg-emerald-500/10 text-emerald-400'
|
||||||
|
: 'border border-slate-700 bg-slate-800 text-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isCompleted ? <Check className="h-4 w-4" /> : index + 1}
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`hidden text-sm font-medium sm:block ${
|
||||||
|
isActive ? 'text-white' : isCompleted ? 'text-emerald-400' : 'text-slate-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{index < steps.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={`mx-3 h-px flex-1 ${
|
||||||
|
index < currentStep ? 'bg-emerald-500' : 'bg-slate-800'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<div className="relative min-h-[400px]">
|
||||||
|
<div
|
||||||
|
className="transition-all duration-300 ease-in-out"
|
||||||
|
style={{
|
||||||
|
opacity: isProcessing ? 0.6 : 1,
|
||||||
|
pointerEvents: isProcessing ? 'none' : 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{currentStep === 0 && (
|
||||||
|
<Step1ChooseTemplate
|
||||||
|
selectedTemplate={template}
|
||||||
|
onSelect={setTemplate}
|
||||||
|
onNext={() => setCurrentStep(1)}
|
||||||
|
onBack={() => router.push('/broadcasts')}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 1 && (
|
||||||
|
<Step2SelectAudience
|
||||||
|
audience={audience}
|
||||||
|
onUpdate={setAudience}
|
||||||
|
onNext={() => setCurrentStep(2)}
|
||||||
|
onBack={() => setCurrentStep(0)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 2 && template && (
|
||||||
|
<Step3Personalize
|
||||||
|
template={template}
|
||||||
|
variables={variables}
|
||||||
|
onUpdate={setVariables}
|
||||||
|
onNext={() => setCurrentStep(3)}
|
||||||
|
onBack={() => setCurrentStep(1)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{currentStep === 3 && template && (
|
||||||
|
<Step4ScheduleSend
|
||||||
|
name={name}
|
||||||
|
onNameChange={setName}
|
||||||
|
template={template}
|
||||||
|
audience={audience}
|
||||||
|
onSend={handleSend}
|
||||||
|
onBack={() => setCurrentStep(2)}
|
||||||
|
isProcessing={isProcessing}
|
||||||
|
progress={progress}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
src/app/(dashboard)/broadcasts/page.tsx
Normal file
170
src/app/(dashboard)/broadcasts/page.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { Broadcast } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Radio, Plus, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
const statusConfig: Record<string, { label: string; classes: string }> = {
|
||||||
|
draft: { label: 'Draft', classes: 'bg-slate-500/10 text-slate-400 border-slate-500/20' },
|
||||||
|
scheduled: { label: 'Scheduled', classes: 'bg-blue-500/10 text-blue-400 border-blue-500/20' },
|
||||||
|
sending: { label: 'Sending', classes: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20' },
|
||||||
|
sent: { label: 'Sent', classes: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' },
|
||||||
|
failed: { label: 'Failed', classes: 'bg-red-500/10 text-red-400 border-red-500/20' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BroadcastsPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [broadcasts, setBroadcasts] = useState<Broadcast[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchBroadcasts() {
|
||||||
|
try {
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data, error: fetchError } = await supabase
|
||||||
|
.from('broadcasts')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (fetchError) throw fetchError;
|
||||||
|
setBroadcasts(data ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load broadcasts');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchBroadcasts();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
<Button variant="outline" onClick={() => window.location.reload()}>
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Broadcasts</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Send bulk messages to your contacts using approved templates.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push('/broadcasts/new')}
|
||||||
|
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Broadcast
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{broadcasts.length === 0 ? (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900">
|
||||||
|
<Radio className="mb-3 h-10 w-10 text-slate-600" />
|
||||||
|
<p className="text-sm font-medium text-white">No broadcasts yet</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
Create your first broadcast to reach your contacts at scale.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => router.push('/broadcasts/new')}
|
||||||
|
className="mt-4 bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
New Broadcast
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||||
|
<TableHead className="text-slate-400">Name</TableHead>
|
||||||
|
<TableHead className="text-slate-400">Template</TableHead>
|
||||||
|
<TableHead className="text-slate-400">Status</TableHead>
|
||||||
|
<TableHead className="text-slate-400 text-right">Recipients</TableHead>
|
||||||
|
<TableHead className="text-slate-400 text-right">Sent</TableHead>
|
||||||
|
<TableHead className="text-slate-400 text-right">Delivered</TableHead>
|
||||||
|
<TableHead className="text-slate-400 text-right">Read</TableHead>
|
||||||
|
<TableHead className="text-slate-400 text-right">Failed</TableHead>
|
||||||
|
<TableHead className="text-slate-400">Created</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{broadcasts.map((broadcast) => {
|
||||||
|
const status = statusConfig[broadcast.status] ?? statusConfig.draft;
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={broadcast.id}
|
||||||
|
className="cursor-pointer border-slate-800 hover:bg-slate-800/50"
|
||||||
|
onClick={() => router.push(`/broadcasts/${broadcast.id}`)}
|
||||||
|
>
|
||||||
|
<TableCell className="font-medium text-white">
|
||||||
|
{broadcast.name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-300">
|
||||||
|
{broadcast.template_name}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium ${status.classes}`}
|
||||||
|
>
|
||||||
|
{status.label}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-slate-300">
|
||||||
|
{broadcast.total_recipients}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-slate-300">
|
||||||
|
{broadcast.sent_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-slate-300">
|
||||||
|
{broadcast.delivered_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-slate-300">
|
||||||
|
{broadcast.read_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right text-slate-300">
|
||||||
|
{broadcast.failed_count}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-400">
|
||||||
|
{new Date(broadcast.created_at).toLocaleDateString()}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
487
src/app/(dashboard)/contacts/page.tsx
Normal file
487
src/app/(dashboard)/contacts/page.tsx
Normal file
@ -0,0 +1,487 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Contact, Tag, ContactTag } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Plus,
|
||||||
|
Upload,
|
||||||
|
MoreHorizontal,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
Loader2,
|
||||||
|
Users,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { ContactForm } from '@/components/contacts/contact-form';
|
||||||
|
import { ContactDetailView } from '@/components/contacts/contact-detail-view';
|
||||||
|
import { ImportModal } from '@/components/contacts/import-modal';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 25;
|
||||||
|
|
||||||
|
interface ContactWithTags extends Contact {
|
||||||
|
tags?: Tag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactsPage() {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const [contacts, setContacts] = useState<ContactWithTags[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
|
|
||||||
|
// Modals
|
||||||
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [editContact, setEditContact] = useState<Contact | null>(null);
|
||||||
|
const [editContactTags, setEditContactTags] = useState<ContactTag[]>([]);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [detailContactId, setDetailContactId] = useState<string | null>(null);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// All tags for display
|
||||||
|
const [tagsMap, setTagsMap] = useState<Record<string, Tag>>({});
|
||||||
|
|
||||||
|
const fetchTags = useCallback(async () => {
|
||||||
|
const { data } = await supabase.from('tags').select('*');
|
||||||
|
if (data) {
|
||||||
|
const map: Record<string, Tag> = {};
|
||||||
|
data.forEach((t) => (map[t.id] = t));
|
||||||
|
setTagsMap(map);
|
||||||
|
}
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
|
const fetchContacts = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const from = page * PAGE_SIZE;
|
||||||
|
const to = from + PAGE_SIZE - 1;
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from('contacts')
|
||||||
|
.select('*', { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
|
if (search.trim()) {
|
||||||
|
const term = `%${search.trim()}%`;
|
||||||
|
query = query.or(`name.ilike.${term},phone.ilike.${term},email.ilike.${term}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, count, error } = await query;
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error('Failed to load contacts');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTotalCount(count ?? 0);
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
setContacts([]);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch tags for these contacts
|
||||||
|
const contactIds = data.map((c) => c.id);
|
||||||
|
const { data: contactTags } = await supabase
|
||||||
|
.from('contact_tags')
|
||||||
|
.select('contact_id, tag_id')
|
||||||
|
.in('contact_id', contactIds);
|
||||||
|
|
||||||
|
const tagsByContact: Record<string, string[]> = {};
|
||||||
|
contactTags?.forEach((ct) => {
|
||||||
|
if (!tagsByContact[ct.contact_id]) tagsByContact[ct.contact_id] = [];
|
||||||
|
tagsByContact[ct.contact_id].push(ct.tag_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const enriched: ContactWithTags[] = data.map((c) => ({
|
||||||
|
...c,
|
||||||
|
tags: (tagsByContact[c.id] ?? [])
|
||||||
|
.map((tid) => tagsMap[tid])
|
||||||
|
.filter(Boolean),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setContacts(enriched);
|
||||||
|
setLoading(false);
|
||||||
|
}, [supabase, page, search, tagsMap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTags();
|
||||||
|
}, [fetchTags]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContacts();
|
||||||
|
}, [fetchContacts]);
|
||||||
|
|
||||||
|
// Reset page when search changes
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(0);
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
function openAddForm() {
|
||||||
|
setEditContact(null);
|
||||||
|
setEditContactTags([]);
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditForm(contact: Contact) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('contact_tags')
|
||||||
|
.select('*')
|
||||||
|
.eq('contact_id', contact.id);
|
||||||
|
setEditContact(contact);
|
||||||
|
setEditContactTags(data ?? []);
|
||||||
|
setFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(contactId: string) {
|
||||||
|
setDetailContactId(contactId);
|
||||||
|
setDetailOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(contact: Contact) {
|
||||||
|
setDeleteTarget(contact);
|
||||||
|
setDeleteConfirmOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setDeleting(true);
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('contacts')
|
||||||
|
.delete()
|
||||||
|
.eq('id', deleteTarget.id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error('Failed to delete contact');
|
||||||
|
} else {
|
||||||
|
toast.success('Contact deleted');
|
||||||
|
fetchContacts();
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeleting(false);
|
||||||
|
setDeleteConfirmOpen(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(totalCount / PAGE_SIZE);
|
||||||
|
const hasNext = page < totalPages - 1;
|
||||||
|
const hasPrev = page > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Contacts</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
Manage your contact list. {totalCount > 0 && `${totalCount} total contacts.`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setImportOpen(true)}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Upload className="size-4" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={openAddForm}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
Add Contact
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative max-w-sm">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-slate-500" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
placeholder="Search by name, phone, or email..."
|
||||||
|
className="pl-8 bg-slate-900 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="rounded-lg border border-slate-800 overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="border-slate-800 hover:bg-transparent">
|
||||||
|
<TableHead className="text-slate-400">Name</TableHead>
|
||||||
|
<TableHead className="text-slate-400">Phone</TableHead>
|
||||||
|
<TableHead className="text-slate-400 hidden md:table-cell">Email</TableHead>
|
||||||
|
<TableHead className="text-slate-400 hidden lg:table-cell">Company</TableHead>
|
||||||
|
<TableHead className="text-slate-400 hidden md:table-cell">Tags</TableHead>
|
||||||
|
<TableHead className="text-slate-400 hidden lg:table-cell">Created</TableHead>
|
||||||
|
<TableHead className="text-slate-400 w-12" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow className="border-slate-800">
|
||||||
|
<TableCell colSpan={7} className="text-center py-12">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Loader2 className="size-6 animate-spin text-emerald-500" />
|
||||||
|
<p className="text-sm text-slate-500">Loading contacts...</p>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : contacts.length === 0 ? (
|
||||||
|
<TableRow className="border-slate-800">
|
||||||
|
<TableCell colSpan={7} className="text-center py-12">
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Users className="size-8 text-slate-600" />
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{search ? 'No contacts match your search.' : 'No contacts yet.'}
|
||||||
|
</p>
|
||||||
|
{!search && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={openAddForm}
|
||||||
|
className="mt-2 border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
Add your first contact
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
contacts.map((contact) => (
|
||||||
|
<TableRow
|
||||||
|
key={contact.id}
|
||||||
|
className="border-slate-800 hover:bg-slate-900/50 cursor-pointer"
|
||||||
|
onClick={() => openDetail(contact.id)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-white font-medium">
|
||||||
|
{contact.name || <span className="text-slate-500 italic">Unnamed</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-300 font-mono text-xs">
|
||||||
|
{contact.phone}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-400 hidden md:table-cell text-sm">
|
||||||
|
{contact.email || <span className="text-slate-600">-</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-400 hidden lg:table-cell text-sm">
|
||||||
|
{contact.company || <span className="text-slate-600">-</span>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{contact.tags && contact.tags.length > 0 ? (
|
||||||
|
contact.tags.slice(0, 3).map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: tag.color + '20',
|
||||||
|
color: tag.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-600 text-xs">-</span>
|
||||||
|
)}
|
||||||
|
{contact.tags && contact.tags.length > 3 && (
|
||||||
|
<span className="text-[10px] text-slate-500">
|
||||||
|
+{contact.tags.length - 3}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-slate-500 text-xs hidden lg:table-cell">
|
||||||
|
{new Date(contact.created_at).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="text-slate-400 hover:text-white"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="bg-slate-900 border-slate-700"
|
||||||
|
>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openEditForm(contact);
|
||||||
|
}}
|
||||||
|
className="text-slate-300 focus:bg-slate-800 focus:text-white"
|
||||||
|
>
|
||||||
|
<Pencil className="size-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator className="bg-slate-700" />
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
confirmDelete(contact);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Showing {page * PAGE_SIZE + 1}-{Math.min((page + 1) * PAGE_SIZE, totalCount)} of{' '}
|
||||||
|
{totalCount}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={() => setPage((p) => p - 1)}
|
||||||
|
className="border-slate-700 text-slate-400 hover:bg-slate-800 hover:text-white disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-slate-400 px-2">
|
||||||
|
Page {page + 1} of {totalPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
className="border-slate-700 text-slate-400 hover:bg-slate-800 hover:text-white disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contact Form Dialog */}
|
||||||
|
<ContactForm
|
||||||
|
open={formOpen}
|
||||||
|
onOpenChange={setFormOpen}
|
||||||
|
contact={editContact}
|
||||||
|
contactTags={editContactTags}
|
||||||
|
onSaved={() => {
|
||||||
|
fetchContacts();
|
||||||
|
fetchTags();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Contact Detail Sheet */}
|
||||||
|
<ContactDetailView
|
||||||
|
open={detailOpen}
|
||||||
|
onOpenChange={setDetailOpen}
|
||||||
|
contactId={detailContactId}
|
||||||
|
onUpdated={fetchContacts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Import Modal */}
|
||||||
|
<ImportModal
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
onImported={fetchContacts}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation */}
|
||||||
|
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">Delete Contact</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
Are you sure you want to delete{' '}
|
||||||
|
<span className="text-slate-200 font-medium">
|
||||||
|
{deleteTarget?.name || deleteTarget?.phone}
|
||||||
|
</span>
|
||||||
|
? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteConfirmOpen(false)}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
>
|
||||||
|
{deleting && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
428
src/app/(dashboard)/dashboard/page.tsx
Normal file
428
src/app/(dashboard)/dashboard/page.tsx
Normal file
@ -0,0 +1,428 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { Conversation, Pipeline, PipelineStage, Broadcast } from "@/types";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
MessageSquare,
|
||||||
|
Mail,
|
||||||
|
TrendingUp,
|
||||||
|
ArrowRight,
|
||||||
|
Radio,
|
||||||
|
GitBranch,
|
||||||
|
DollarSign,
|
||||||
|
Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function formatCurrency(value: number) {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(dateStr: string) {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = now.getTime() - date.getTime();
|
||||||
|
const diffMin = Math.floor(diffMs / 60000);
|
||||||
|
const diffHr = Math.floor(diffMin / 60);
|
||||||
|
const diffDay = Math.floor(diffHr / 24);
|
||||||
|
|
||||||
|
if (diffMin < 1) return "Just now";
|
||||||
|
if (diffMin < 60) return `${diffMin}m ago`;
|
||||||
|
if (diffHr < 24) return `${diffHr}h ago`;
|
||||||
|
if (diffDay < 7) return `${diffDay}d ago`;
|
||||||
|
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, icon, loading }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900 p-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium text-slate-400">{title}</p>
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-emerald-500/10 text-emerald-500">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="mt-3 h-8 w-20 animate-pulse rounded bg-slate-800" />
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-3xl font-bold text-white">{value}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineOverviewData {
|
||||||
|
pipeline: Pipeline;
|
||||||
|
stages: (PipelineStage & { dealCount: number; totalValue: number })[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [totalContacts, setTotalContacts] = useState(0);
|
||||||
|
const [openConversations, setOpenConversations] = useState(0);
|
||||||
|
const [messagesToday, setMessagesToday] = useState(0);
|
||||||
|
const [activeDeals, setActiveDeals] = useState(0);
|
||||||
|
const [recentConversations, setRecentConversations] = useState<Conversation[]>([]);
|
||||||
|
const [pipelineOverviews, setPipelineOverviews] = useState<PipelineOverviewData[]>([]);
|
||||||
|
const [recentBroadcasts, setRecentBroadcasts] = useState<Broadcast[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboardData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadDashboardData() {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const todayStart = new Date();
|
||||||
|
todayStart.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const [
|
||||||
|
contactsRes,
|
||||||
|
openConvRes,
|
||||||
|
messagesTodayRes,
|
||||||
|
dealsRes,
|
||||||
|
recentConvRes,
|
||||||
|
pipelinesRes,
|
||||||
|
broadcastsRes,
|
||||||
|
] = await Promise.all([
|
||||||
|
supabase.from("contacts").select("id", { count: "exact", head: true }),
|
||||||
|
supabase
|
||||||
|
.from("conversations")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.eq("status", "open"),
|
||||||
|
supabase
|
||||||
|
.from("messages")
|
||||||
|
.select("id", { count: "exact", head: true })
|
||||||
|
.gte("created_at", todayStart.toISOString()),
|
||||||
|
supabase.from("deals").select("id", { count: "exact", head: true }),
|
||||||
|
supabase
|
||||||
|
.from("conversations")
|
||||||
|
.select("*, contact:contacts(*)")
|
||||||
|
.order("last_message_at", { ascending: false })
|
||||||
|
.limit(5),
|
||||||
|
supabase.from("pipelines").select("*").order("created_at"),
|
||||||
|
supabase
|
||||||
|
.from("broadcasts")
|
||||||
|
.select("*")
|
||||||
|
.order("created_at", { ascending: false })
|
||||||
|
.limit(3),
|
||||||
|
]);
|
||||||
|
|
||||||
|
setTotalContacts(contactsRes.count ?? 0);
|
||||||
|
setOpenConversations(openConvRes.count ?? 0);
|
||||||
|
setMessagesToday(messagesTodayRes.count ?? 0);
|
||||||
|
setActiveDeals(dealsRes.count ?? 0);
|
||||||
|
setRecentConversations(recentConvRes.data ?? []);
|
||||||
|
setRecentBroadcasts(broadcastsRes.data ?? []);
|
||||||
|
|
||||||
|
// Load pipeline overviews with stages and deal data
|
||||||
|
if (pipelinesRes.data && pipelinesRes.data.length > 0) {
|
||||||
|
const overviews: PipelineOverviewData[] = [];
|
||||||
|
for (const pipeline of pipelinesRes.data) {
|
||||||
|
const { data: stagesData } = await supabase
|
||||||
|
.from("pipeline_stages")
|
||||||
|
.select("*")
|
||||||
|
.eq("pipeline_id", pipeline.id)
|
||||||
|
.order("position");
|
||||||
|
|
||||||
|
const { data: dealsData } = await supabase
|
||||||
|
.from("deals")
|
||||||
|
.select("stage_id, value")
|
||||||
|
.eq("pipeline_id", pipeline.id);
|
||||||
|
|
||||||
|
const enrichedStages = (stagesData || []).map((stage) => {
|
||||||
|
const stageDeals = (dealsData || []).filter(
|
||||||
|
(d) => d.stage_id === stage.id
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...stage,
|
||||||
|
dealCount: stageDeals.length,
|
||||||
|
totalValue: stageDeals.reduce(
|
||||||
|
(sum: number, d: { value: number }) => sum + d.value,
|
||||||
|
0
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
overviews.push({ pipeline, stages: enrichedStages });
|
||||||
|
}
|
||||||
|
setPipelineOverviews(overviews);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBroadcastStatusColor(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case "sent":
|
||||||
|
return "bg-emerald-500/10 text-emerald-400";
|
||||||
|
case "sending":
|
||||||
|
return "bg-blue-500/10 text-blue-400";
|
||||||
|
case "scheduled":
|
||||||
|
return "bg-yellow-500/10 text-yellow-400";
|
||||||
|
case "draft":
|
||||||
|
return "bg-slate-500/10 text-slate-400";
|
||||||
|
case "failed":
|
||||||
|
return "bg-red-500/10 text-red-400";
|
||||||
|
default:
|
||||||
|
return "bg-slate-500/10 text-slate-400";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Overview of your WhatsApp CRM activity
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 1: Stat Cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title="Total Contacts"
|
||||||
|
value={totalContacts.toLocaleString()}
|
||||||
|
icon={<Users className="h-5 w-5" />}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Open Conversations"
|
||||||
|
value={openConversations.toLocaleString()}
|
||||||
|
icon={<MessageSquare className="h-5 w-5" />}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Messages Today"
|
||||||
|
value={messagesToday.toLocaleString()}
|
||||||
|
icon={<Mail className="h-5 w-5" />}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Active Deals"
|
||||||
|
value={activeDeals.toLocaleString()}
|
||||||
|
icon={<TrendingUp className="h-5 w-5" />}
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Recent Conversations */}
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="h-4 w-4 text-emerald-500" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">
|
||||||
|
Recent Conversations
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/dashboard/inbox")}
|
||||||
|
className="flex items-center gap-1 text-xs text-emerald-400 hover:text-emerald-300 transition-colors"
|
||||||
|
>
|
||||||
|
View all
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-800">
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 px-5 py-3">
|
||||||
|
<div className="h-9 w-9 animate-pulse rounded-full bg-slate-800" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 w-28 animate-pulse rounded bg-slate-800" />
|
||||||
|
<div className="mt-1.5 h-3 w-48 animate-pulse rounded bg-slate-800" />
|
||||||
|
</div>
|
||||||
|
<div className="h-3 w-12 animate-pulse rounded bg-slate-800" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : recentConversations.length === 0 ? (
|
||||||
|
<div className="px-5 py-8 text-center text-sm text-slate-500">
|
||||||
|
No conversations yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
recentConversations.map((conv) => (
|
||||||
|
<button
|
||||||
|
key={conv.id}
|
||||||
|
onClick={() => router.push("/dashboard/inbox")}
|
||||||
|
className="flex w-full items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-slate-800/50"
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
||||||
|
{conv.contact?.name?.charAt(0)?.toUpperCase() ||
|
||||||
|
conv.contact?.phone?.slice(-2) ||
|
||||||
|
"?"}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{conv.contact?.name || conv.contact?.phone || "Unknown"}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-400 truncate">
|
||||||
|
{conv.last_message_text || "No messages"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-end gap-1 shrink-0">
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{conv.last_message_at
|
||||||
|
? formatRelativeTime(conv.last_message_at)
|
||||||
|
: ""}
|
||||||
|
</span>
|
||||||
|
{conv.unread_count > 0 && (
|
||||||
|
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-emerald-500 px-1 text-[10px] font-medium text-white">
|
||||||
|
{conv.unread_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 3: Pipeline Overview */}
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-800 px-5 py-4">
|
||||||
|
<GitBranch className="h-4 w-4 text-emerald-500" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">
|
||||||
|
Pipeline Overview
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<div className="h-4 w-32 animate-pulse rounded bg-slate-800" />
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
{[1, 2, 3, 4].map((j) => (
|
||||||
|
<div
|
||||||
|
key={j}
|
||||||
|
className="h-16 flex-1 animate-pulse rounded-lg bg-slate-800/50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : pipelineOverviews.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-slate-500">
|
||||||
|
No pipelines created yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{pipelineOverviews.map(({ pipeline, stages }) => (
|
||||||
|
<div key={pipeline.id}>
|
||||||
|
<h3 className="text-sm font-medium text-white mb-3">
|
||||||
|
{pipeline.name}
|
||||||
|
</h3>
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{stages.map((stage) => (
|
||||||
|
<div
|
||||||
|
key={stage.id}
|
||||||
|
className="min-w-[140px] flex-1 rounded-lg border border-slate-800 bg-slate-800/30 p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-1.5 mb-2">
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: stage.color }}
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-medium text-slate-300 truncate">
|
||||||
|
{stage.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-bold text-white">
|
||||||
|
{stage.dealCount}
|
||||||
|
</p>
|
||||||
|
{stage.totalValue > 0 && (
|
||||||
|
<p className="flex items-center gap-1 text-xs text-emerald-400 mt-0.5">
|
||||||
|
<DollarSign className="h-3 w-3" />
|
||||||
|
{formatCurrency(stage.totalValue)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 4: Recent Broadcasts */}
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900">
|
||||||
|
<div className="flex items-center gap-2 border-b border-slate-800 px-5 py-4">
|
||||||
|
<Radio className="h-4 w-4 text-emerald-500" />
|
||||||
|
<h2 className="text-sm font-semibold text-white">
|
||||||
|
Recent Broadcasts
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-800">
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 2 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex items-center gap-3 px-5 py-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="h-4 w-36 animate-pulse rounded bg-slate-800" />
|
||||||
|
<div className="mt-1.5 h-3 w-24 animate-pulse rounded bg-slate-800" />
|
||||||
|
</div>
|
||||||
|
<div className="h-5 w-16 animate-pulse rounded-full bg-slate-800" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : recentBroadcasts.length === 0 ? (
|
||||||
|
<div className="px-5 py-8 text-center text-sm text-slate-500">
|
||||||
|
No broadcasts yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
recentBroadcasts.map((broadcast) => (
|
||||||
|
<div
|
||||||
|
key={broadcast.id}
|
||||||
|
className="flex items-center gap-4 px-5 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-slate-800 text-slate-400">
|
||||||
|
<Radio className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-white truncate">
|
||||||
|
{broadcast.name}
|
||||||
|
</p>
|
||||||
|
<div className="mt-0.5 flex items-center gap-3 text-xs text-slate-400">
|
||||||
|
<span>
|
||||||
|
{broadcast.sent_count}/{broadcast.total_recipients} sent
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{formatRelativeTime(broadcast.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2.5 py-0.5 text-xs font-medium capitalize ${getBroadcastStatusColor(
|
||||||
|
broadcast.status
|
||||||
|
)}`}
|
||||||
|
>
|
||||||
|
{broadcast.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
204
src/app/(dashboard)/inbox/page.tsx
Normal file
204
src/app/(dashboard)/inbox/page.tsx
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useCallback, useEffect } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
|
||||||
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
|
import { ConversationList } from "@/components/inbox/conversation-list";
|
||||||
|
import { MessageThread } from "@/components/inbox/message-thread";
|
||||||
|
import { ContactSidebar } from "@/components/inbox/contact-sidebar";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Wifi, WifiOff } from "lucide-react";
|
||||||
|
|
||||||
|
export default function InboxPage() {
|
||||||
|
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||||
|
const [activeConversation, setActiveConversation] =
|
||||||
|
useState<Conversation | null>(null);
|
||||||
|
const [activeContact, setActiveContact] = useState<Contact | null>(null);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [whatsappConnected, setWhatsappConnected] = useState<boolean | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check WhatsApp connection status on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const checkConnection = async () => {
|
||||||
|
const supabase = createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("whatsapp_configs")
|
||||||
|
.select("status")
|
||||||
|
.eq("user_id", user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
setWhatsappConnected(data?.status === "connected");
|
||||||
|
};
|
||||||
|
|
||||||
|
checkConnection();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle realtime message events
|
||||||
|
const handleMessageEvent = useCallback(
|
||||||
|
(event: { eventType: string; new: Message; old: Partial<Message> }) => {
|
||||||
|
const newMsg = event.new;
|
||||||
|
|
||||||
|
if (event.eventType === "INSERT") {
|
||||||
|
// Add to messages if it belongs to active conversation
|
||||||
|
if (
|
||||||
|
activeConversation &&
|
||||||
|
newMsg.conversation_id === activeConversation.id
|
||||||
|
) {
|
||||||
|
setMessages((prev) => {
|
||||||
|
// Avoid duplicates
|
||||||
|
if (prev.some((m) => m.id === newMsg.id)) return prev;
|
||||||
|
// Replace optimistic message if it exists
|
||||||
|
const withoutOptimistic = prev.filter(
|
||||||
|
(m) => !m.id.startsWith("temp-")
|
||||||
|
);
|
||||||
|
return [...withoutOptimistic, newMsg];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update conversation list preview
|
||||||
|
setConversations((prev) =>
|
||||||
|
prev.map((c) =>
|
||||||
|
c.id === newMsg.conversation_id
|
||||||
|
? {
|
||||||
|
...c,
|
||||||
|
last_message_text: newMsg.content_text ?? "",
|
||||||
|
last_message_at: newMsg.created_at,
|
||||||
|
unread_count:
|
||||||
|
activeConversation?.id === newMsg.conversation_id
|
||||||
|
? 0
|
||||||
|
: c.unread_count + 1,
|
||||||
|
}
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.eventType === "UPDATE") {
|
||||||
|
// Update message status
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) => (m.id === newMsg.id ? { ...m, ...newMsg } : m))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeConversation]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle realtime conversation events
|
||||||
|
const handleConversationEvent = useCallback(
|
||||||
|
(event: {
|
||||||
|
eventType: string;
|
||||||
|
new: Conversation;
|
||||||
|
old: Partial<Conversation>;
|
||||||
|
}) => {
|
||||||
|
const conv = event.new;
|
||||||
|
|
||||||
|
if (event.eventType === "INSERT") {
|
||||||
|
setConversations((prev) => [conv, ...prev]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.eventType === "UPDATE") {
|
||||||
|
setConversations((prev) =>
|
||||||
|
prev.map((c) => (c.id === conv.id ? { ...c, ...conv } : c))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update active conversation if it changed
|
||||||
|
if (activeConversation && conv.id === activeConversation.id) {
|
||||||
|
setActiveConversation((prev) =>
|
||||||
|
prev ? { ...prev, ...conv } : prev
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeConversation]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Subscribe to realtime
|
||||||
|
useRealtime({
|
||||||
|
channelName: "inbox-realtime",
|
||||||
|
onMessageEvent: handleMessageEvent,
|
||||||
|
onConversationEvent: handleConversationEvent,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleConversationsLoaded = useCallback(
|
||||||
|
(loaded: Conversation[]) => {
|
||||||
|
setConversations(loaded);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectConversation = useCallback((conv: Conversation) => {
|
||||||
|
setActiveConversation(conv);
|
||||||
|
setActiveContact(conv.contact ?? null);
|
||||||
|
setMessages([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMessagesLoaded = useCallback((loaded: Message[]) => {
|
||||||
|
setMessages(loaded);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleNewMessage = useCallback((msg: Message) => {
|
||||||
|
setMessages((prev) => {
|
||||||
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
||||||
|
return [...prev, msg];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleStatusChange = useCallback(
|
||||||
|
(conversationId: string, status: ConversationStatus) => {
|
||||||
|
setConversations((prev) =>
|
||||||
|
prev.map((c) => (c.id === conversationId ? { ...c, status } : c))
|
||||||
|
);
|
||||||
|
if (activeConversation?.id === conversationId) {
|
||||||
|
setActiveConversation((prev) => (prev ? { ...prev, status } : prev));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeConversation]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="-m-6 flex h-[calc(100vh-3.5rem)] overflow-hidden">
|
||||||
|
{/* WhatsApp connection banner */}
|
||||||
|
{whatsappConnected === false && (
|
||||||
|
<div className="absolute left-60 right-0 top-14 z-10 flex items-center justify-center gap-2 bg-amber-500/10 px-4 py-2">
|
||||||
|
<WifiOff className="h-4 w-4 text-amber-400" />
|
||||||
|
<p className="text-xs text-amber-400">
|
||||||
|
WhatsApp is not connected. Go to Settings to connect your account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Left panel: Conversation list */}
|
||||||
|
<ConversationList
|
||||||
|
activeConversationId={activeConversation?.id ?? null}
|
||||||
|
onSelect={handleSelectConversation}
|
||||||
|
conversations={conversations}
|
||||||
|
onConversationsLoaded={handleConversationsLoaded}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Center panel: Message thread */}
|
||||||
|
<MessageThread
|
||||||
|
conversation={activeConversation}
|
||||||
|
contact={activeContact}
|
||||||
|
messages={messages}
|
||||||
|
onMessagesLoaded={handleMessagesLoaded}
|
||||||
|
onNewMessage={handleNewMessage}
|
||||||
|
onStatusChange={handleStatusChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Right panel: Contact sidebar (hidden on small screens) */}
|
||||||
|
<div className="hidden lg:block">
|
||||||
|
<ContactSidebar contact={activeContact} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/app/(dashboard)/layout.tsx
Normal file
47
src/app/(dashboard)/layout.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { Sidebar } from "@/components/layout/sidebar";
|
||||||
|
import { Header } from "@/components/layout/header";
|
||||||
|
|
||||||
|
export default function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !user) {
|
||||||
|
router.push("/login");
|
||||||
|
}
|
||||||
|
}, [user, loading, router]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen items-center justify-center bg-slate-950">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||||
|
<p className="text-sm text-slate-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen overflow-hidden bg-slate-950">
|
||||||
|
<Sidebar />
|
||||||
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
|
<Header />
|
||||||
|
<main className="flex-1 overflow-y-auto p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
301
src/app/(dashboard)/pipelines/page.tsx
Normal file
301
src/app/(dashboard)/pipelines/page.tsx
Normal file
@ -0,0 +1,301 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { Pipeline, PipelineStage } from "@/types";
|
||||||
|
import { PipelineBoard } from "@/components/pipelines/pipeline-board";
|
||||||
|
import { PipelineSettings } from "@/components/pipelines/pipeline-settings";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
GitBranch,
|
||||||
|
Plus,
|
||||||
|
Settings,
|
||||||
|
ChevronDown,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const DEFAULT_STAGES = [
|
||||||
|
{ name: "Lead", color: "#6366f1", position: 0 },
|
||||||
|
{ name: "Qualified", color: "#8b5cf6", position: 1 },
|
||||||
|
{ name: "Proposal", color: "#f97316", position: 2 },
|
||||||
|
{ name: "Negotiation", color: "#eab308", position: 3 },
|
||||||
|
{ name: "Won", color: "#22c55e", position: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PipelinesPage() {
|
||||||
|
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
|
||||||
|
const [stages, setStages] = useState<PipelineStage[]>([]);
|
||||||
|
const [selectedPipelineId, setSelectedPipelineId] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [newPipelineOpen, setNewPipelineOpen] = useState(false);
|
||||||
|
const [newPipelineName, setNewPipelineName] = useState("");
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPipelines();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedPipelineId) {
|
||||||
|
loadStages(selectedPipelineId);
|
||||||
|
}
|
||||||
|
}, [selectedPipelineId]);
|
||||||
|
|
||||||
|
async function loadPipelines() {
|
||||||
|
setLoading(true);
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("pipelines")
|
||||||
|
.select("*")
|
||||||
|
.order("created_at");
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
setPipelines(data);
|
||||||
|
if (!selectedPipelineId || !data.find((p) => p.id === selectedPipelineId)) {
|
||||||
|
setSelectedPipelineId(data[0].id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setPipelines([]);
|
||||||
|
setSelectedPipelineId("");
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStages(pipelineId: string) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("pipeline_stages")
|
||||||
|
.select("*")
|
||||||
|
.eq("pipeline_id", pipelineId)
|
||||||
|
.order("position");
|
||||||
|
if (data) setStages(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreatePipeline() {
|
||||||
|
if (!newPipelineName.trim()) return;
|
||||||
|
setCreating(true);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
setCreating(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: pipeline } = await supabase
|
||||||
|
.from("pipelines")
|
||||||
|
.insert({ user_id: user.id, name: newPipelineName.trim() })
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (pipeline) {
|
||||||
|
const stageInserts = DEFAULT_STAGES.map((s) => ({
|
||||||
|
pipeline_id: pipeline.id,
|
||||||
|
name: s.name,
|
||||||
|
color: s.color,
|
||||||
|
position: s.position,
|
||||||
|
}));
|
||||||
|
await supabase.from("pipeline_stages").insert(stageInserts);
|
||||||
|
|
||||||
|
setNewPipelineName("");
|
||||||
|
setNewPipelineOpen(false);
|
||||||
|
setSelectedPipelineId(pipeline.id);
|
||||||
|
await loadPipelines();
|
||||||
|
}
|
||||||
|
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRefresh() {
|
||||||
|
loadPipelines();
|
||||||
|
if (selectedPipelineId) {
|
||||||
|
loadStages(selectedPipelineId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPipeline = pipelines.find((p) => p.id === selectedPipelineId);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="h-7 w-32 animate-pulse rounded bg-slate-800" />
|
||||||
|
<div className="mt-2 h-4 w-48 animate-pulse rounded bg-slate-800" />
|
||||||
|
</div>
|
||||||
|
<div className="h-8 w-28 animate-pulse rounded-lg bg-slate-800" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-96 w-72 animate-pulse rounded-xl bg-slate-800/50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Pipelines</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Manage your deals and sales pipeline
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedPipeline && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setSettingsOpen(true)}
|
||||||
|
className="text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
onClick={() => setNewPipelineOpen(true)}
|
||||||
|
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
New Pipeline
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pipeline selector */}
|
||||||
|
{pipelines.length > 1 && (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-white hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<GitBranch className="h-4 w-4 text-emerald-500" />
|
||||||
|
{selectedPipeline?.name || "Select Pipeline"}
|
||||||
|
<ChevronDown className="h-4 w-4 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
{dropdownOpen && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setDropdownOpen(false)}
|
||||||
|
/>
|
||||||
|
<div className="absolute top-full left-0 z-20 mt-1 w-56 rounded-lg border border-slate-700 bg-slate-900 py-1 shadow-lg">
|
||||||
|
{pipelines.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPipelineId(p.id);
|
||||||
|
setDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className={`flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors ${
|
||||||
|
p.id === selectedPipelineId
|
||||||
|
? "bg-emerald-500/10 text-emerald-400"
|
||||||
|
: "text-slate-300 hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<GitBranch className="h-3.5 w-3.5" />
|
||||||
|
{p.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Board or empty state */}
|
||||||
|
{pipelines.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-slate-700 py-20">
|
||||||
|
<GitBranch className="h-12 w-12 text-slate-600" />
|
||||||
|
<h3 className="mt-4 text-lg font-medium text-white">
|
||||||
|
No pipelines yet
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-400">
|
||||||
|
Create your first pipeline to start tracking deals
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => setNewPipelineOpen(true)}
|
||||||
|
className="mt-4 bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
Create Pipeline
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<PipelineBoard
|
||||||
|
pipelineId={selectedPipelineId}
|
||||||
|
stages={stages}
|
||||||
|
onStagesChange={() => loadStages(selectedPipelineId)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New Pipeline Dialog */}
|
||||||
|
<Dialog open={newPipelineOpen} onOpenChange={setNewPipelineOpen}>
|
||||||
|
<DialogContent className="sm:max-w-sm bg-slate-900 border-slate-700">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">New Pipeline</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-2">
|
||||||
|
<Label className="text-slate-300">Pipeline Name</Label>
|
||||||
|
<Input
|
||||||
|
value={newPipelineName}
|
||||||
|
onChange={(e) => setNewPipelineName(e.target.value)}
|
||||||
|
placeholder="e.g., Sales Pipeline"
|
||||||
|
className="mt-2 bg-slate-800 border-slate-700 text-white"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleCreatePipeline();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-2 text-xs text-slate-400">
|
||||||
|
Default stages (Lead, Qualified, Proposal, Negotiation, Won) will
|
||||||
|
be created automatically.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setNewPipelineOpen(false)}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreatePipeline}
|
||||||
|
disabled={creating || !newPipelineName.trim()}
|
||||||
|
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
{creating ? "Creating..." : "Create Pipeline"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Pipeline Settings */}
|
||||||
|
{selectedPipeline && (
|
||||||
|
<PipelineSettings
|
||||||
|
open={settingsOpen}
|
||||||
|
onOpenChange={setSettingsOpen}
|
||||||
|
pipeline={selectedPipeline}
|
||||||
|
stages={stages}
|
||||||
|
onUpdated={handleRefresh}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
src/app/(dashboard)/settings/page.tsx
Normal file
49
src/app/(dashboard)/settings/page.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Settings, MessageSquare, Tag } from 'lucide-react';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { WhatsAppConfig } from '@/components/settings/whatsapp-config';
|
||||||
|
import { TemplateManager } from '@/components/settings/template-manager';
|
||||||
|
import { TagManager } from '@/components/settings/tag-manager';
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Settings</h1>
|
||||||
|
<p className="text-sm text-slate-400 mt-1">
|
||||||
|
Manage your WhatsApp integration, message templates, and tags.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="whatsapp">
|
||||||
|
<TabsList className="bg-slate-900 border border-slate-700">
|
||||||
|
<TabsTrigger value="whatsapp" className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400">
|
||||||
|
<Settings className="size-4" />
|
||||||
|
WhatsApp Config
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="templates" className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400">
|
||||||
|
<MessageSquare className="size-4" />
|
||||||
|
Templates
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tags" className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400">
|
||||||
|
<Tag className="size-4" />
|
||||||
|
Tags
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="whatsapp">
|
||||||
|
<WhatsAppConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="templates">
|
||||||
|
<TemplateManager />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="tags">
|
||||||
|
<TagManager />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/app/api/whatsapp/broadcast/route.ts
Normal file
123
src/app/api/whatsapp/broadcast/route.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||||
|
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||||
|
import { sanitizePhoneForMeta, isValidE164 } from '@/lib/whatsapp/phone-utils'
|
||||||
|
|
||||||
|
interface BroadcastResult {
|
||||||
|
phone: string
|
||||||
|
status: 'sent' | 'failed'
|
||||||
|
whatsapp_message_id?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error: authError,
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (authError || !user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { phone_numbers, template_name, template_language, template_params } = body
|
||||||
|
|
||||||
|
if (!phone_numbers || !Array.isArray(phone_numbers) || phone_numbers.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'phone_numbers array is required and must not be empty' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!template_name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'template_name is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and decrypt WhatsApp config
|
||||||
|
const { data: config, error: configError } = await supabase
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (configError || !config) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'WhatsApp not configured. Please set up your WhatsApp integration first.' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = decrypt(config.access_token)
|
||||||
|
|
||||||
|
const results: BroadcastResult[] = []
|
||||||
|
let sentCount = 0
|
||||||
|
let failedCount = 0
|
||||||
|
|
||||||
|
for (const phone of phone_numbers) {
|
||||||
|
const sanitized = sanitizePhoneForMeta(phone)
|
||||||
|
|
||||||
|
if (!isValidE164(sanitized)) {
|
||||||
|
results.push({
|
||||||
|
phone,
|
||||||
|
status: 'failed',
|
||||||
|
error: 'Invalid phone number format',
|
||||||
|
})
|
||||||
|
failedCount++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await sendTemplateMessage(
|
||||||
|
accessToken,
|
||||||
|
config.phone_number_id,
|
||||||
|
sanitized,
|
||||||
|
template_name,
|
||||||
|
template_params || [],
|
||||||
|
template_language || 'en_US'
|
||||||
|
)
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
phone,
|
||||||
|
status: 'sent',
|
||||||
|
whatsapp_message_id: result.messageId,
|
||||||
|
})
|
||||||
|
sentCount++
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error ? error.message : 'Unknown error'
|
||||||
|
console.error(`Failed to send broadcast to ${phone}:`, error)
|
||||||
|
results.push({
|
||||||
|
phone,
|
||||||
|
status: 'failed',
|
||||||
|
error: errorMessage,
|
||||||
|
})
|
||||||
|
failedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
total: phone_numbers.length,
|
||||||
|
sent: sentCount,
|
||||||
|
failed: failedCount,
|
||||||
|
results,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in WhatsApp broadcast POST:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to process broadcast' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/app/api/whatsapp/config/route.ts
Normal file
98
src/app/api/whatsapp/config/route.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { verifyPhoneNumber } from '@/lib/whatsapp/meta-api'
|
||||||
|
import { encrypt } from '@/lib/whatsapp/encryption'
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error: authError,
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (authError || !user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const { phone_number_id, waba_id, access_token, verify_token } = body
|
||||||
|
|
||||||
|
if (!access_token || !phone_number_id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'access_token and phone_number_id are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the phone number with Meta API
|
||||||
|
const phoneInfo = await verifyPhoneNumber(access_token, phone_number_id)
|
||||||
|
|
||||||
|
// Encrypt sensitive tokens
|
||||||
|
const encryptedAccessToken = encrypt(access_token)
|
||||||
|
const encryptedVerifyToken = verify_token ? encrypt(verify_token) : null
|
||||||
|
|
||||||
|
// Check if config already exists for this user
|
||||||
|
const { data: existing } = await supabase
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.select('id')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Update existing config
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.update({
|
||||||
|
phone_number_id,
|
||||||
|
waba_id: waba_id || null,
|
||||||
|
access_token: encryptedAccessToken,
|
||||||
|
verify_token: encryptedVerifyToken,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error('Error updating whatsapp_config:', updateError)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update configuration' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Insert new config
|
||||||
|
const { error: insertError } = await supabase
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
phone_number_id,
|
||||||
|
waba_id: waba_id || null,
|
||||||
|
access_token: encryptedAccessToken,
|
||||||
|
verify_token: encryptedVerifyToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (insertError) {
|
||||||
|
console.error('Error inserting whatsapp_config:', insertError)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to save configuration' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
phone_info: phoneInfo,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in WhatsApp config POST:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
70
src/app/api/whatsapp/media/[mediaId]/route.ts
Normal file
70
src/app/api/whatsapp/media/[mediaId]/route.ts
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
|
||||||
|
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ mediaId: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const { mediaId } = await params
|
||||||
|
|
||||||
|
if (!mediaId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Media ID is required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error: authError,
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (authError || !user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and decrypt WhatsApp config
|
||||||
|
const { data: config, error: configError } = await supabase
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (configError || !config) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'WhatsApp not configured' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = decrypt(config.access_token)
|
||||||
|
|
||||||
|
// Get the download URL from Meta
|
||||||
|
const mediaInfo = await getMediaUrl(mediaId, accessToken)
|
||||||
|
|
||||||
|
// Download the binary data
|
||||||
|
const { buffer, contentType } = await downloadMedia(mediaInfo.url, accessToken)
|
||||||
|
|
||||||
|
return new Response(new Uint8Array(buffer), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': contentType || mediaInfo.mimeType || 'application/octet-stream',
|
||||||
|
'Cache-Control': 'public, max-age=86400',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in WhatsApp media GET:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch media' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
171
src/app/api/whatsapp/send/route.ts
Normal file
171
src/app/api/whatsapp/send/route.ts
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { createClient } from '@/lib/supabase/server'
|
||||||
|
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||||
|
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||||
|
import { sanitizePhoneForMeta, isValidE164 } from '@/lib/whatsapp/phone-utils'
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const supabase = await createClient()
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
error: authError,
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
if (authError || !user) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
conversation_id,
|
||||||
|
message_type,
|
||||||
|
content_text,
|
||||||
|
media_url,
|
||||||
|
template_name,
|
||||||
|
template_params,
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!conversation_id || !message_type) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'conversation_id and message_type are required' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message_type === 'text' && !content_text) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'content_text is required for text messages' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message_type === 'template' && !template_name) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'template_name is required for template messages' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch conversation and contact
|
||||||
|
const { data: conversation, error: convError } = await supabase
|
||||||
|
.from('conversations')
|
||||||
|
.select('*, contact:contacts(*)')
|
||||||
|
.eq('id', conversation_id)
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (convError || !conversation) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Conversation not found' },
|
||||||
|
{ status: 404 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const contact = conversation.contact
|
||||||
|
if (!contact?.phone) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Contact phone number not found' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize and validate phone
|
||||||
|
const sanitizedPhone = sanitizePhoneForMeta(contact.phone)
|
||||||
|
if (!isValidE164(sanitizedPhone)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Invalid phone number format' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch and decrypt WhatsApp config
|
||||||
|
const { data: config, error: configError } = await supabase
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (configError || !config) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'WhatsApp not configured. Please set up your WhatsApp integration first.' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const accessToken = decrypt(config.access_token)
|
||||||
|
|
||||||
|
// Send via Meta API
|
||||||
|
let waMessageId: string
|
||||||
|
|
||||||
|
if (message_type === 'template') {
|
||||||
|
const result = await sendTemplateMessage(
|
||||||
|
accessToken,
|
||||||
|
config.phone_number_id,
|
||||||
|
sanitizedPhone,
|
||||||
|
template_name,
|
||||||
|
template_params || []
|
||||||
|
)
|
||||||
|
waMessageId = result.messageId
|
||||||
|
} else {
|
||||||
|
const result = await sendTextMessage(
|
||||||
|
accessToken,
|
||||||
|
config.phone_number_id,
|
||||||
|
sanitizedPhone,
|
||||||
|
content_text
|
||||||
|
)
|
||||||
|
waMessageId = result.messageId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert message into DB
|
||||||
|
const { data: messageRecord, error: msgError } = await supabase
|
||||||
|
.from('messages')
|
||||||
|
.insert({
|
||||||
|
conversation_id,
|
||||||
|
user_id: user.id,
|
||||||
|
whatsapp_message_id: waMessageId,
|
||||||
|
direction: 'outbound',
|
||||||
|
message_type,
|
||||||
|
content_text: content_text || null,
|
||||||
|
media_url: media_url || null,
|
||||||
|
status: 'sent',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (msgError) {
|
||||||
|
console.error('Error inserting sent message:', msgError)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Message sent but failed to save to database' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update conversation
|
||||||
|
await supabase
|
||||||
|
.from('conversations')
|
||||||
|
.update({
|
||||||
|
last_message_text: content_text || `[${message_type}]`,
|
||||||
|
last_message_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', conversation_id)
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message_id: messageRecord.id,
|
||||||
|
whatsapp_message_id: waMessageId,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in WhatsApp send POST:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to send message' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
450
src/app/api/whatsapp/webhook/route.ts
Normal file
450
src/app/api/whatsapp/webhook/route.ts
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
import { NextResponse } from 'next/server'
|
||||||
|
import { createClient } from '@supabase/supabase-js'
|
||||||
|
import { decrypt } from '@/lib/whatsapp/encryption'
|
||||||
|
import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
|
||||||
|
import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils'
|
||||||
|
|
||||||
|
// Lazy-initialized to avoid build-time crash when env vars are missing
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
let _adminClient: any = null
|
||||||
|
function supabaseAdmin() {
|
||||||
|
if (!_adminClient) {
|
||||||
|
_adminClient = createClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return _adminClient
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WhatsAppMessage {
|
||||||
|
id: string
|
||||||
|
from: string
|
||||||
|
timestamp: string
|
||||||
|
type: string
|
||||||
|
text?: { body: string }
|
||||||
|
image?: { id: string; mime_type: string; caption?: string }
|
||||||
|
video?: { id: string; mime_type: string; caption?: string }
|
||||||
|
document?: { id: string; mime_type: string; filename?: string; caption?: string }
|
||||||
|
audio?: { id: string; mime_type: string }
|
||||||
|
location?: { latitude: number; longitude: number; name?: string; address?: string }
|
||||||
|
reaction?: { message_id: string; emoji: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WhatsAppWebhookEntry {
|
||||||
|
id: string
|
||||||
|
changes: Array<{
|
||||||
|
value: {
|
||||||
|
messaging_product: string
|
||||||
|
metadata: {
|
||||||
|
display_phone_number: string
|
||||||
|
phone_number_id: string
|
||||||
|
}
|
||||||
|
contacts?: Array<{
|
||||||
|
profile: { name: string }
|
||||||
|
wa_id: string
|
||||||
|
}>
|
||||||
|
messages?: WhatsAppMessage[]
|
||||||
|
statuses?: Array<{
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
timestamp: string
|
||||||
|
recipient_id: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
field: string
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET - Webhook verification
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const mode = searchParams.get('hub.mode')
|
||||||
|
const challenge = searchParams.get('hub.challenge')
|
||||||
|
const verifyToken = searchParams.get('hub.verify_token')
|
||||||
|
|
||||||
|
if (mode !== 'subscribe' || !challenge || !verifyToken) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Missing verification parameters' },
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch all whatsapp configs to check verify tokens
|
||||||
|
const { data: configs, error: configError } = await supabaseAdmin()
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.select('id, verify_token')
|
||||||
|
|
||||||
|
if (configError || !configs) {
|
||||||
|
console.error('Error fetching configs for verification:', configError)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verification failed' },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any config's verify_token matches
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const matched = configs.some((config: any) => {
|
||||||
|
if (!config.verify_token) return false
|
||||||
|
try {
|
||||||
|
const decrypted = decrypt(config.verify_token)
|
||||||
|
return decrypted === verifyToken
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (matched) {
|
||||||
|
// Return challenge as plain text
|
||||||
|
return new Response(challenge, {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'text/plain' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Verification token mismatch' },
|
||||||
|
{ status: 403 }
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in webhook GET verification:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST - Receive messages
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
// Always return 200 immediately to acknowledge receipt
|
||||||
|
const body = await request.json()
|
||||||
|
|
||||||
|
// Process asynchronously
|
||||||
|
processWebhook(body).catch((error) => {
|
||||||
|
console.error('Error processing webhook:', error)
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json({ status: 'received' }, { status: 200 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processWebhook(body: { entry?: WhatsAppWebhookEntry[] }) {
|
||||||
|
if (!body.entry) return
|
||||||
|
|
||||||
|
for (const entry of body.entry) {
|
||||||
|
for (const change of entry.changes) {
|
||||||
|
const value = change.value
|
||||||
|
|
||||||
|
// Handle status updates
|
||||||
|
if (value.statuses) {
|
||||||
|
for (const status of value.statuses) {
|
||||||
|
await handleStatusUpdate(status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle incoming messages
|
||||||
|
if (!value.messages || !value.contacts) continue
|
||||||
|
|
||||||
|
const phoneNumberId = value.metadata.phone_number_id
|
||||||
|
|
||||||
|
// Find user's config by phone_number_id
|
||||||
|
const { data: config, error: configError } = await supabaseAdmin()
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.select('*')
|
||||||
|
.eq('phone_number_id', phoneNumberId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (configError || !config) {
|
||||||
|
console.error('No config found for phone_number_id:', phoneNumberId)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedAccessToken = decrypt(config.access_token)
|
||||||
|
|
||||||
|
for (let i = 0; i < value.messages.length; i++) {
|
||||||
|
const message = value.messages[i]
|
||||||
|
const contact = value.contacts[i] || value.contacts[0]
|
||||||
|
|
||||||
|
await processMessage(
|
||||||
|
message,
|
||||||
|
contact,
|
||||||
|
config.user_id,
|
||||||
|
decryptedAccessToken
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStatusUpdate(status: {
|
||||||
|
id: string
|
||||||
|
status: string
|
||||||
|
timestamp: string
|
||||||
|
recipient_id: string
|
||||||
|
}) {
|
||||||
|
const { error } = await supabaseAdmin()
|
||||||
|
.from('messages')
|
||||||
|
.update({
|
||||||
|
status: status.status,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('whatsapp_message_id', status.id)
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Error updating message status:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processMessage(
|
||||||
|
message: WhatsAppMessage,
|
||||||
|
contact: { profile: { name: string }; wa_id: string },
|
||||||
|
userId: string,
|
||||||
|
accessToken: string
|
||||||
|
) {
|
||||||
|
const senderPhone = normalizePhone(message.from)
|
||||||
|
const contactName = contact.profile.name
|
||||||
|
|
||||||
|
// Parse message content based on type
|
||||||
|
const { contentText, mediaUrl, mediaType } = await parseMessageContent(
|
||||||
|
message,
|
||||||
|
accessToken
|
||||||
|
)
|
||||||
|
|
||||||
|
// Find or create contact
|
||||||
|
const contactRecord = await findOrCreateContact(
|
||||||
|
userId,
|
||||||
|
senderPhone,
|
||||||
|
contactName
|
||||||
|
)
|
||||||
|
if (!contactRecord) return
|
||||||
|
|
||||||
|
// Find or create conversation
|
||||||
|
const conversation = await findOrCreateConversation(
|
||||||
|
userId,
|
||||||
|
contactRecord.id
|
||||||
|
)
|
||||||
|
if (!conversation) return
|
||||||
|
|
||||||
|
// Insert message
|
||||||
|
const { error: msgError } = await supabaseAdmin().from('messages').insert({
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
user_id: userId,
|
||||||
|
whatsapp_message_id: message.id,
|
||||||
|
direction: 'inbound',
|
||||||
|
message_type: message.type,
|
||||||
|
content_text: contentText,
|
||||||
|
media_url: mediaUrl,
|
||||||
|
media_type: mediaType,
|
||||||
|
status: 'received',
|
||||||
|
timestamp: new Date(parseInt(message.timestamp) * 1000).toISOString(),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (msgError) {
|
||||||
|
console.error('Error inserting message:', msgError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update conversation
|
||||||
|
const { error: convError } = await supabaseAdmin()
|
||||||
|
.from('conversations')
|
||||||
|
.update({
|
||||||
|
last_message_text: contentText || `[${message.type}]`,
|
||||||
|
last_message_at: new Date().toISOString(),
|
||||||
|
unread_count: (conversation.unread_count || 0) + 1,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', conversation.id)
|
||||||
|
|
||||||
|
if (convError) {
|
||||||
|
console.error('Error updating conversation:', convError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseMessageContent(
|
||||||
|
message: WhatsAppMessage,
|
||||||
|
accessToken: string
|
||||||
|
): Promise<{
|
||||||
|
contentText: string | null
|
||||||
|
mediaUrl: string | null
|
||||||
|
mediaType: string | null
|
||||||
|
}> {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'text':
|
||||||
|
return {
|
||||||
|
contentText: message.text?.body || null,
|
||||||
|
mediaUrl: null,
|
||||||
|
mediaType: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'image':
|
||||||
|
if (message.image) {
|
||||||
|
try {
|
||||||
|
await getMediaUrl(accessToken, message.image.id)
|
||||||
|
return {
|
||||||
|
contentText: message.image.caption || null,
|
||||||
|
mediaUrl: `/api/whatsapp/media/${message.image.id}`,
|
||||||
|
mediaType: message.image.mime_type,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying image media:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { contentText: message.image?.caption || null, mediaUrl: null, mediaType: null }
|
||||||
|
|
||||||
|
case 'video':
|
||||||
|
if (message.video) {
|
||||||
|
try {
|
||||||
|
await getMediaUrl(accessToken, message.video.id)
|
||||||
|
return {
|
||||||
|
contentText: message.video.caption || null,
|
||||||
|
mediaUrl: `/api/whatsapp/media/${message.video.id}`,
|
||||||
|
mediaType: message.video.mime_type,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying video media:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { contentText: message.video?.caption || null, mediaUrl: null, mediaType: null }
|
||||||
|
|
||||||
|
case 'document':
|
||||||
|
if (message.document) {
|
||||||
|
try {
|
||||||
|
await getMediaUrl(accessToken, message.document.id)
|
||||||
|
return {
|
||||||
|
contentText: message.document.caption || message.document.filename || null,
|
||||||
|
mediaUrl: `/api/whatsapp/media/${message.document.id}`,
|
||||||
|
mediaType: message.document.mime_type,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying document media:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { contentText: message.document?.filename || null, mediaUrl: null, mediaType: null }
|
||||||
|
|
||||||
|
case 'audio':
|
||||||
|
if (message.audio) {
|
||||||
|
try {
|
||||||
|
await getMediaUrl(accessToken, message.audio.id)
|
||||||
|
return {
|
||||||
|
contentText: null,
|
||||||
|
mediaUrl: `/api/whatsapp/media/${message.audio.id}`,
|
||||||
|
mediaType: message.audio.mime_type,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error verifying audio media:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
||||||
|
|
||||||
|
case 'location':
|
||||||
|
if (message.location) {
|
||||||
|
const loc = message.location
|
||||||
|
const locationText = [loc.name, loc.address, `${loc.latitude},${loc.longitude}`]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' - ')
|
||||||
|
return {
|
||||||
|
contentText: locationText,
|
||||||
|
mediaUrl: null,
|
||||||
|
mediaType: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { contentText: null, mediaUrl: null, mediaType: null }
|
||||||
|
|
||||||
|
case 'reaction':
|
||||||
|
return {
|
||||||
|
contentText: message.reaction?.emoji || null,
|
||||||
|
mediaUrl: null,
|
||||||
|
mediaType: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
contentText: `[Unsupported message type: ${message.type}]`,
|
||||||
|
mediaUrl: null,
|
||||||
|
mediaType: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateContact(
|
||||||
|
userId: string,
|
||||||
|
phone: string,
|
||||||
|
name: string
|
||||||
|
) {
|
||||||
|
// Look up existing contacts for this user
|
||||||
|
const { data: contacts, error: contactsError } = await supabaseAdmin()
|
||||||
|
.from('contacts')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
|
||||||
|
if (contactsError) {
|
||||||
|
console.error('Error fetching contacts:', contactsError)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use phonesMatch for flexible matching
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const existingContact = contacts?.find((c: any) => phonesMatch(c.phone, phone))
|
||||||
|
|
||||||
|
if (existingContact) {
|
||||||
|
// Update name if it changed
|
||||||
|
if (name && name !== existingContact.name) {
|
||||||
|
await supabaseAdmin()
|
||||||
|
.from('contacts')
|
||||||
|
.update({ name, updated_at: new Date().toISOString() })
|
||||||
|
.eq('id', existingContact.id)
|
||||||
|
}
|
||||||
|
return existingContact
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new contact
|
||||||
|
const { data: newContact, error: createError } = await supabaseAdmin()
|
||||||
|
.from('contacts')
|
||||||
|
.insert({
|
||||||
|
user_id: userId,
|
||||||
|
phone,
|
||||||
|
name: name || phone,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (createError) {
|
||||||
|
console.error('Error creating contact:', createError)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return newContact
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findOrCreateConversation(userId: string, contactId: string) {
|
||||||
|
// Look for existing conversation
|
||||||
|
const { data: existing, error: findError } = await supabaseAdmin()
|
||||||
|
.from('conversations')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.eq('contact_id', contactId)
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (!findError && existing) {
|
||||||
|
return existing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new conversation
|
||||||
|
const { data: newConv, error: createError } = await supabaseAdmin()
|
||||||
|
.from('conversations')
|
||||||
|
.insert({
|
||||||
|
user_id: userId,
|
||||||
|
contact_id: contactId,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single()
|
||||||
|
|
||||||
|
if (createError) {
|
||||||
|
console.error('Error creating conversation:', createError)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return newConv
|
||||||
|
}
|
||||||
@ -49,72 +49,38 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: oklch(1 0 0);
|
--background: oklch(0.13 0.01 260);
|
||||||
--foreground: oklch(0.145 0 0);
|
--foreground: oklch(0.985 0 0);
|
||||||
--card: oklch(1 0 0);
|
--card: oklch(0.18 0.01 260);
|
||||||
--card-foreground: oklch(0.145 0 0);
|
--card-foreground: oklch(0.985 0 0);
|
||||||
--popover: oklch(1 0 0);
|
--popover: oklch(0.18 0.01 260);
|
||||||
--popover-foreground: oklch(0.145 0 0);
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
--primary: oklch(0.205 0 0);
|
--primary: oklch(0.696 0.17 162);
|
||||||
--primary-foreground: oklch(0.985 0 0);
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
--secondary: oklch(0.97 0 0);
|
--secondary: oklch(0.22 0.01 260);
|
||||||
--secondary-foreground: oklch(0.205 0 0);
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
--muted: oklch(0.97 0 0);
|
--muted: oklch(0.22 0.01 260);
|
||||||
--muted-foreground: oklch(0.556 0 0);
|
--muted-foreground: oklch(0.65 0.01 260);
|
||||||
--accent: oklch(0.97 0 0);
|
--accent: oklch(0.22 0.01 260);
|
||||||
--accent-foreground: oklch(0.205 0 0);
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--border: oklch(0.922 0 0);
|
--border: oklch(0.28 0.01 260);
|
||||||
--input: oklch(0.922 0 0);
|
--input: oklch(0.28 0.01 260);
|
||||||
--ring: oklch(0.708 0 0);
|
--ring: oklch(0.696 0.17 162);
|
||||||
--chart-1: oklch(0.87 0 0);
|
--chart-1: oklch(0.696 0.17 162);
|
||||||
--chart-2: oklch(0.556 0 0);
|
--chart-2: oklch(0.556 0 0);
|
||||||
--chart-3: oklch(0.439 0 0);
|
--chart-3: oklch(0.439 0 0);
|
||||||
--chart-4: oklch(0.371 0 0);
|
--chart-4: oklch(0.371 0 0);
|
||||||
--chart-5: oklch(0.269 0 0);
|
--chart-5: oklch(0.269 0 0);
|
||||||
--radius: 0.625rem;
|
--radius: 0.625rem;
|
||||||
--sidebar: oklch(0.985 0 0);
|
--sidebar: oklch(0.16 0.01 260);
|
||||||
--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-foreground: oklch(0.985 0 0);
|
--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-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-accent-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-border: oklch(1 0 0 / 10%);
|
--sidebar-border: oklch(0.28 0.01 260);
|
||||||
--sidebar-ring: oklch(0.556 0 0);
|
--sidebar-ring: oklch(0.696 0.17 162);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
|||||||
@ -1,20 +1,16 @@
|
|||||||
import type { Metadata } from "next";
|
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";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const inter = Inter({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-sans",
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "WaCRM - WhatsApp CRM",
|
||||||
description: "Generated by create next app",
|
description: "WhatsApp CRM for managing conversations, contacts, and pipelines",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -23,11 +19,21 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="en" className={`${inter.variable} h-full antialiased`}>
|
||||||
lang="en"
|
<body className="min-h-full bg-slate-950 text-white font-sans">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
{children}
|
||||||
>
|
<Toaster
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
theme="dark"
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
style: {
|
||||||
|
background: "rgb(30 41 59)",
|
||||||
|
border: "1px solid rgb(51 65 85)",
|
||||||
|
color: "white",
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,65 +1,5 @@
|
|||||||
import Image from "next/image";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
redirect("/login");
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/next.svg"
|
|
||||||
alt="Next.js logo"
|
|
||||||
width={100}
|
|
||||||
height={20}
|
|
||||||
priority
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
Documentation
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
134
src/components/broadcasts/step1-choose-template.tsx
Normal file
134
src/components/broadcasts/step1-choose-template.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { MessageTemplate } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader2, FileText, ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
Marketing: 'bg-purple-500/10 text-purple-400 border-purple-500/20',
|
||||||
|
Utility: 'bg-blue-500/10 text-blue-400 border-blue-500/20',
|
||||||
|
Authentication: 'bg-orange-500/10 text-orange-400 border-orange-500/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Step1Props {
|
||||||
|
selectedTemplate: MessageTemplate | null;
|
||||||
|
onSelect: (template: MessageTemplate) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step1ChooseTemplate({ selectedTemplate, onSelect, onNext, onBack }: Step1Props) {
|
||||||
|
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchTemplates() {
|
||||||
|
try {
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data, error: fetchError } = await supabase
|
||||||
|
.from('message_templates')
|
||||||
|
.select('*')
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (fetchError) throw fetchError;
|
||||||
|
setTemplates(data ?? []);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load templates');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchTemplates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 items-center justify-center">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-2">
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Choose a Template</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Select an approved message template for your broadcast.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<div className="flex h-48 flex-col items-center justify-center rounded-xl border border-slate-800 bg-slate-900/50">
|
||||||
|
<FileText className="mb-2 h-8 w-8 text-slate-600" />
|
||||||
|
<p className="text-sm text-slate-400">No templates available.</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Create a template in Settings first.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{templates.map((template) => {
|
||||||
|
const isSelected = selectedTemplate?.id === template.id;
|
||||||
|
const catColor = categoryColors[template.category] ?? categoryColors.Utility;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
onClick={() => onSelect(template)}
|
||||||
|
className={`flex flex-col gap-3 rounded-xl border p-4 text-left transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-emerald-500 bg-emerald-500/5 ring-1 ring-emerald-500/30'
|
||||||
|
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700 hover:bg-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-white">{template.name}</h3>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${catColor}`}
|
||||||
|
>
|
||||||
|
{template.category}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="line-clamp-3 text-xs text-slate-400">{template.body_text}</p>
|
||||||
|
<div className="flex items-center gap-2 text-[10px] text-slate-500">
|
||||||
|
<span>{template.language ?? 'en_US'}</span>
|
||||||
|
{template.status && (
|
||||||
|
<>
|
||||||
|
<span>-</span>
|
||||||
|
<span>{template.status}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||||
|
<Button variant="outline" onClick={onBack} className="border-slate-700 text-slate-300">
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!selectedTemplate}
|
||||||
|
className="bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
208
src/components/broadcasts/step2-select-audience.tsx
Normal file
208
src/components/broadcasts/step2-select-audience.tsx
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { Tag } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Users, Tags, Filter, Upload, Loader2, ArrowRight, ArrowLeft } from 'lucide-react';
|
||||||
|
|
||||||
|
type AudienceType = 'all' | 'tags' | 'custom_field' | 'csv';
|
||||||
|
|
||||||
|
interface AudienceConfig {
|
||||||
|
type: AudienceType;
|
||||||
|
tagIds?: string[];
|
||||||
|
csvContacts?: { phone: string; name?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Step2Props {
|
||||||
|
audience: AudienceConfig;
|
||||||
|
onUpdate: (audience: AudienceConfig) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const audienceOptions: { type: AudienceType; label: string; description: string; icon: typeof Users }[] = [
|
||||||
|
{ type: 'all', label: 'All Contacts', description: 'Send to every contact in your database', icon: Users },
|
||||||
|
{ type: 'tags', label: 'Filter by Tags', description: 'Target contacts with specific tags', icon: Tags },
|
||||||
|
{ type: 'custom_field', label: 'Custom Field', description: 'Filter by a custom field value', icon: Filter },
|
||||||
|
{ type: 'csv', label: 'Upload CSV', description: 'Upload a list of phone numbers', icon: Upload },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Step2SelectAudience({ audience, onUpdate, onNext, onBack }: Step2Props) {
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [loadingTags, setLoadingTags] = useState(false);
|
||||||
|
const [estimatedCount, setEstimatedCount] = useState<number | null>(null);
|
||||||
|
const [loadingCount, setLoadingCount] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audience.type === 'tags') {
|
||||||
|
async function fetchTags() {
|
||||||
|
setLoadingTags(true);
|
||||||
|
try {
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data } = await supabase.from('tags').select('*').order('name');
|
||||||
|
setTags(data ?? []);
|
||||||
|
} finally {
|
||||||
|
setLoadingTags(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchTags();
|
||||||
|
}
|
||||||
|
}, [audience.type]);
|
||||||
|
|
||||||
|
const fetchEstimatedCount = useCallback(async () => {
|
||||||
|
setLoadingCount(true);
|
||||||
|
try {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
if (audience.type === 'all') {
|
||||||
|
const { count } = await supabase
|
||||||
|
.from('contacts')
|
||||||
|
.select('*', { count: 'exact', head: true });
|
||||||
|
setEstimatedCount(count ?? 0);
|
||||||
|
} else if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
|
||||||
|
const { data: contactTags } = await supabase
|
||||||
|
.from('contact_tags')
|
||||||
|
.select('contact_id')
|
||||||
|
.in('tag_id', audience.tagIds);
|
||||||
|
|
||||||
|
const uniqueIds = new Set((contactTags ?? []).map((ct) => ct.contact_id));
|
||||||
|
setEstimatedCount(uniqueIds.size);
|
||||||
|
} else if (audience.type === 'csv' && audience.csvContacts) {
|
||||||
|
setEstimatedCount(audience.csvContacts.length);
|
||||||
|
} else {
|
||||||
|
setEstimatedCount(null);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingCount(false);
|
||||||
|
}
|
||||||
|
}, [audience.type, audience.tagIds, audience.csvContacts]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audience.type === 'all' || (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) || (audience.type === 'csv' && audience.csvContacts && audience.csvContacts.length > 0)) {
|
||||||
|
fetchEstimatedCount();
|
||||||
|
} else {
|
||||||
|
setEstimatedCount(null);
|
||||||
|
}
|
||||||
|
}, [audience.type, audience.tagIds, audience.csvContacts, fetchEstimatedCount]);
|
||||||
|
|
||||||
|
function toggleTag(tagId: string) {
|
||||||
|
const current = audience.tagIds ?? [];
|
||||||
|
const updated = current.includes(tagId)
|
||||||
|
? current.filter((id) => id !== tagId)
|
||||||
|
: [...current, tagId];
|
||||||
|
onUpdate({ ...audience, tagIds: updated });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid =
|
||||||
|
audience.type === 'all' ||
|
||||||
|
(audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) ||
|
||||||
|
(audience.type === 'csv' && audience.csvContacts && audience.csvContacts.length > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Select Audience</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">Choose who will receive this broadcast.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
{audienceOptions.map((option) => {
|
||||||
|
const isSelected = audience.type === option.type;
|
||||||
|
const Icon = option.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.type}
|
||||||
|
onClick={() => onUpdate({ type: option.type, tagIds: option.type === 'tags' ? audience.tagIds : undefined })}
|
||||||
|
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-emerald-500 bg-emerald-500/5 ring-1 ring-emerald-500/30'
|
||||||
|
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
|
||||||
|
isSelected ? 'bg-emerald-500/10 text-emerald-400' : 'bg-slate-800 text-slate-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{option.label}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-400">{option.description}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{audience.type === 'tags' && (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||||
|
<p className="mb-3 text-sm font-medium text-white">Select Tags</p>
|
||||||
|
{loadingTags ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-emerald-500" />
|
||||||
|
) : tags.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-400">No tags found. Create tags in Settings.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const isSelected = audience.tagIds?.includes(tag.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => toggleTag(tag.id)}
|
||||||
|
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
||||||
|
: 'border-slate-700 bg-slate-800 text-slate-300 hover:border-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="mr-1.5 h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Audience Summary */}
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||||
|
<p className="mb-2 text-sm font-medium text-white">Audience Summary</p>
|
||||||
|
{loadingCount ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-emerald-500" />
|
||||||
|
<span className="text-xs text-slate-400">Calculating...</span>
|
||||||
|
</div>
|
||||||
|
) : estimatedCount !== null ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="h-4 w-4 text-emerald-400" />
|
||||||
|
<span className="text-sm text-white">{estimatedCount.toLocaleString()}</span>
|
||||||
|
<span className="text-xs text-slate-400">estimated recipients</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500">Select an audience type to see the estimate.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||||
|
<Button variant="outline" onClick={onBack} className="border-slate-700 text-slate-300">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
disabled={!isValid}
|
||||||
|
className="bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
189
src/components/broadcasts/step3-personalize.tsx
Normal file
189
src/components/broadcasts/step3-personalize.tsx
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { MessageTemplate } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { ArrowLeft, ArrowRight, Eye } from 'lucide-react';
|
||||||
|
|
||||||
|
interface VariableMapping {
|
||||||
|
type: 'static' | 'field';
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Step3Props {
|
||||||
|
template: MessageTemplate;
|
||||||
|
variables: Record<string, VariableMapping>;
|
||||||
|
onUpdate: (variables: Record<string, VariableMapping>) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactFields = [
|
||||||
|
{ value: 'name', label: 'Contact Name' },
|
||||||
|
{ value: 'phone', label: 'Phone Number' },
|
||||||
|
{ value: 'email', label: 'Email Address' },
|
||||||
|
{ value: 'company', label: 'Company' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const sampleContact = {
|
||||||
|
name: 'John Doe',
|
||||||
|
phone: '+1234567890',
|
||||||
|
email: 'john@example.com',
|
||||||
|
company: 'Acme Corp',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Step3Personalize({ template, variables, onUpdate, onNext, onBack }: Step3Props) {
|
||||||
|
// Extract {{1}}, {{2}}, etc. from body_text
|
||||||
|
const placeholders = useMemo(() => {
|
||||||
|
const matches = template.body_text.match(/\{\{(\d+)\}\}/g);
|
||||||
|
if (!matches) return [];
|
||||||
|
return [...new Set(matches)].sort();
|
||||||
|
}, [template.body_text]);
|
||||||
|
|
||||||
|
function updateVariable(key: string, field: Partial<VariableMapping>) {
|
||||||
|
const current = variables[key] ?? { type: 'static', value: '' };
|
||||||
|
onUpdate({
|
||||||
|
...variables,
|
||||||
|
[key]: { ...current, ...field },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate preview with sample data
|
||||||
|
const previewText = useMemo(() => {
|
||||||
|
let text = template.body_text;
|
||||||
|
for (const placeholder of placeholders) {
|
||||||
|
const key = placeholder;
|
||||||
|
const mapping = variables[key];
|
||||||
|
let replacement = placeholder;
|
||||||
|
|
||||||
|
if (mapping) {
|
||||||
|
if (mapping.type === 'static' && mapping.value) {
|
||||||
|
replacement = mapping.value;
|
||||||
|
} else if (mapping.type === 'field' && mapping.value) {
|
||||||
|
replacement = sampleContact[mapping.value as keyof typeof sampleContact] ?? placeholder;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text = text.replaceAll(placeholder, replacement);
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}, [template.body_text, variables, placeholders]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Personalize Message</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Map template variables to contact fields or static values.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{placeholders.length === 0 ? (
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-6 text-center">
|
||||||
|
<p className="text-sm text-slate-400">This template has no variables to personalize.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{placeholders.map((placeholder) => {
|
||||||
|
const mapping = variables[placeholder] ?? { type: 'static', value: '' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={placeholder} className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<span className="inline-flex items-center rounded-md bg-emerald-500/10 px-2 py-0.5 text-xs font-mono font-medium text-emerald-400">
|
||||||
|
{placeholder}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-slate-400">
|
||||||
|
Mapping Type
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={mapping.type}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
updateVariable(placeholder, { type: val as 'static' | 'field', value: '' })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="border-slate-700 bg-slate-800">
|
||||||
|
<SelectItem value="static">Static Value</SelectItem>
|
||||||
|
<SelectItem value="field">Contact Field</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-xs font-medium text-slate-400">
|
||||||
|
{mapping.type === 'static' ? 'Value' : 'Field'}
|
||||||
|
</label>
|
||||||
|
{mapping.type === 'static' ? (
|
||||||
|
<Input
|
||||||
|
value={mapping.value}
|
||||||
|
onChange={(e) => updateVariable(placeholder, { value: e.target.value })}
|
||||||
|
placeholder="Enter value..."
|
||||||
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Select
|
||||||
|
value={mapping.value || undefined}
|
||||||
|
onValueChange={(val) => updateVariable(placeholder, { value: val || '' })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full border-slate-700 bg-slate-800 text-white">
|
||||||
|
<SelectValue placeholder="Select field..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="border-slate-700 bg-slate-800">
|
||||||
|
{contactFields.map((field) => (
|
||||||
|
<SelectItem key={field.value} value={field.value}>
|
||||||
|
{field.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
<Eye className="h-4 w-4 text-emerald-400" />
|
||||||
|
<p className="text-sm font-medium text-white">Preview</p>
|
||||||
|
<span className="text-xs text-slate-500">(with sample data)</span>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-800/50 p-3">
|
||||||
|
<p className="whitespace-pre-wrap text-sm text-slate-200">{previewText}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||||
|
<Button variant="outline" onClick={onBack} className="border-slate-700 text-slate-300">
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onNext}
|
||||||
|
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
256
src/components/broadcasts/step4-schedule-send.tsx
Normal file
256
src/components/broadcasts/step4-schedule-send.tsx
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { MessageTemplate } from '@/types';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { ArrowLeft, Send, Clock, Loader2, Users, Lock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AudienceConfig {
|
||||||
|
type: string;
|
||||||
|
tagIds?: string[];
|
||||||
|
csvContacts?: { phone: string; name?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Step4Props {
|
||||||
|
name: string;
|
||||||
|
onNameChange: (name: string) => void;
|
||||||
|
template: MessageTemplate;
|
||||||
|
audience: AudienceConfig;
|
||||||
|
onSend: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
isProcessing: boolean;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Step4ScheduleSend({
|
||||||
|
name,
|
||||||
|
onNameChange,
|
||||||
|
template,
|
||||||
|
audience,
|
||||||
|
onSend,
|
||||||
|
onBack,
|
||||||
|
isProcessing,
|
||||||
|
progress,
|
||||||
|
}: Step4Props) {
|
||||||
|
const [timing, setTiming] = useState<'now' | 'later'>('now');
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [estimatedReach, setEstimatedReach] = useState<number>(0);
|
||||||
|
const [loadingReach, setLoadingReach] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function calculateReach() {
|
||||||
|
setLoadingReach(true);
|
||||||
|
try {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
if (audience.type === 'all') {
|
||||||
|
const { count } = await supabase
|
||||||
|
.from('contacts')
|
||||||
|
.select('*', { count: 'exact', head: true });
|
||||||
|
setEstimatedReach(count ?? 0);
|
||||||
|
} else if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
|
||||||
|
const { data: contactTags } = await supabase
|
||||||
|
.from('contact_tags')
|
||||||
|
.select('contact_id')
|
||||||
|
.in('tag_id', audience.tagIds);
|
||||||
|
|
||||||
|
const uniqueIds = new Set((contactTags ?? []).map((ct) => ct.contact_id));
|
||||||
|
setEstimatedReach(uniqueIds.size);
|
||||||
|
} else if (audience.type === 'csv' && audience.csvContacts) {
|
||||||
|
setEstimatedReach(audience.csvContacts.length);
|
||||||
|
} else {
|
||||||
|
setEstimatedReach(0);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoadingReach(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
calculateReach();
|
||||||
|
}, [audience]);
|
||||||
|
|
||||||
|
const audienceLabel =
|
||||||
|
audience.type === 'all'
|
||||||
|
? 'All Contacts'
|
||||||
|
: audience.type === 'tags'
|
||||||
|
? `Tags (${audience.tagIds?.length ?? 0} selected)`
|
||||||
|
: audience.type === 'csv'
|
||||||
|
? 'CSV Upload'
|
||||||
|
: 'Custom';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Review & Send</h2>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">
|
||||||
|
Name your broadcast, review the details, and send.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Broadcast Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-white">Broadcast Name</label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => onNameChange(e.target.value)}
|
||||||
|
placeholder="e.g. Summer Sale Announcement"
|
||||||
|
className="border-slate-700 bg-slate-800 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Card */}
|
||||||
|
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4 space-y-3">
|
||||||
|
<p className="text-sm font-medium text-white">Summary</p>
|
||||||
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">Template</p>
|
||||||
|
<p className="text-white">{template.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">Audience</p>
|
||||||
|
<p className="text-white">{audienceLabel}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">Estimated Reach</p>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{loadingReach ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Users className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
<p className="font-medium text-white">{estimatedReach.toLocaleString()}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-slate-400">Language</p>
|
||||||
|
<p className="text-white">{template.language ?? 'en_US'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delivery Timing */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm font-medium text-white">Delivery Timing</p>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setTiming('now')}
|
||||||
|
className={`flex items-start gap-3 rounded-xl border p-4 text-left transition-all ${
|
||||||
|
timing === 'now'
|
||||||
|
? 'border-emerald-500 bg-emerald-500/5 ring-1 ring-emerald-500/30'
|
||||||
|
: 'border-slate-800 bg-slate-900/50 hover:border-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Send className={`mt-0.5 h-4 w-4 ${timing === 'now' ? 'text-emerald-400' : 'text-slate-400'}`} />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">Send Immediately</p>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-400">Start sending right away</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="relative flex items-start gap-3 rounded-xl border border-slate-800 bg-slate-900/30 p-4 opacity-60">
|
||||||
|
<Clock className="mt-0.5 h-4 w-4 text-slate-500" />
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-sm font-medium text-slate-400">Schedule for Later</p>
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-slate-800 px-2 py-0.5 text-[10px] font-medium text-slate-400">
|
||||||
|
<Lock className="h-2.5 w-2.5" />
|
||||||
|
Pro
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-500">Pick a date and time</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Processing overlay */}
|
||||||
|
{isProcessing && (
|
||||||
|
<div className="rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-emerald-500" />
|
||||||
|
<p className="text-sm font-medium text-white">Sending broadcast...</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-medium text-emerald-400">{progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full rounded-full bg-slate-800">
|
||||||
|
<div
|
||||||
|
className="h-1.5 rounded-full bg-emerald-500 transition-all duration-300"
|
||||||
|
style={{ width: `${progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between border-t border-slate-800 pt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={isProcessing}
|
||||||
|
className="border-slate-700 text-slate-300"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Dialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||||
|
<DialogTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
disabled={!name.trim() || isProcessing}
|
||||||
|
className="bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Send Broadcast
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="border-slate-700 bg-slate-900 sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">Confirm Broadcast</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
You are about to send this broadcast to{' '}
|
||||||
|
<span className="font-medium text-white">{estimatedReach.toLocaleString()}</span>{' '}
|
||||||
|
contacts using the{' '}
|
||||||
|
<span className="font-medium text-white">{template.name}</span> template.
|
||||||
|
This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
className="border-slate-700 text-slate-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setShowConfirm(false);
|
||||||
|
onSend();
|
||||||
|
}}
|
||||||
|
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
Confirm & Send
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
602
src/components/contacts/contact-detail-view.tsx
Normal file
602
src/components/contacts/contact-detail-view.tsx
Normal file
@ -0,0 +1,602 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Contact, Tag, ContactTag, ContactNote, CustomField, ContactCustomValue } from '@/types';
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
} from '@/components/ui/sheet';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import {
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
Building2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
interface ContactDetailViewProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
contactId: string | null;
|
||||||
|
onUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactDetailView({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
contactId,
|
||||||
|
onUpdated,
|
||||||
|
}: ContactDetailViewProps) {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const [contact, setContact] = useState<Contact | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [copiedPhone, setCopiedPhone] = useState(false);
|
||||||
|
|
||||||
|
// Details tab
|
||||||
|
const [editName, setEditName] = useState('');
|
||||||
|
const [editPhone, setEditPhone] = useState('');
|
||||||
|
const [editEmail, setEditEmail] = useState('');
|
||||||
|
const [editCompany, setEditCompany] = useState('');
|
||||||
|
const [savingDetails, setSavingDetails] = useState(false);
|
||||||
|
|
||||||
|
// Tags tab
|
||||||
|
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||||
|
const [contactTagIds, setContactTagIds] = useState<string[]>([]);
|
||||||
|
const [savingTags, setSavingTags] = useState(false);
|
||||||
|
|
||||||
|
// Notes tab
|
||||||
|
const [notes, setNotes] = useState<ContactNote[]>([]);
|
||||||
|
const [newNote, setNewNote] = useState('');
|
||||||
|
const [savingNote, setSavingNote] = useState(false);
|
||||||
|
const [loadingNotes, setLoadingNotes] = useState(false);
|
||||||
|
|
||||||
|
// Custom fields tab
|
||||||
|
const [customFields, setCustomFields] = useState<CustomField[]>([]);
|
||||||
|
const [customValues, setCustomValues] = useState<Record<string, string>>({});
|
||||||
|
const [savingCustom, setSavingCustom] = useState(false);
|
||||||
|
const [loadingCustom, setLoadingCustom] = useState(false);
|
||||||
|
|
||||||
|
const fetchContact = useCallback(async () => {
|
||||||
|
if (!contactId) return;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('contacts')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', contactId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setContact(data);
|
||||||
|
setEditName(data.name ?? '');
|
||||||
|
setEditPhone(data.phone);
|
||||||
|
setEditEmail(data.email ?? '');
|
||||||
|
setEditCompany(data.company ?? '');
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [contactId, supabase]);
|
||||||
|
|
||||||
|
const fetchTags = useCallback(async () => {
|
||||||
|
if (!contactId) return;
|
||||||
|
|
||||||
|
const [tagsRes, contactTagsRes] = await Promise.all([
|
||||||
|
supabase.from('tags').select('*').order('name'),
|
||||||
|
supabase.from('contact_tags').select('tag_id').eq('contact_id', contactId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (tagsRes.data) setAllTags(tagsRes.data);
|
||||||
|
if (contactTagsRes.data) {
|
||||||
|
setContactTagIds(contactTagsRes.data.map((ct) => ct.tag_id));
|
||||||
|
}
|
||||||
|
}, [contactId, supabase]);
|
||||||
|
|
||||||
|
const fetchNotes = useCallback(async () => {
|
||||||
|
if (!contactId) return;
|
||||||
|
setLoadingNotes(true);
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('contact_notes')
|
||||||
|
.select('*')
|
||||||
|
.eq('contact_id', contactId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (data) setNotes(data);
|
||||||
|
setLoadingNotes(false);
|
||||||
|
}, [contactId, supabase]);
|
||||||
|
|
||||||
|
const fetchCustomFields = useCallback(async () => {
|
||||||
|
if (!contactId) return;
|
||||||
|
setLoadingCustom(true);
|
||||||
|
|
||||||
|
const [fieldsRes, valuesRes] = await Promise.all([
|
||||||
|
supabase.from('custom_fields').select('*').order('field_name'),
|
||||||
|
supabase
|
||||||
|
.from('contact_custom_values')
|
||||||
|
.select('*')
|
||||||
|
.eq('contact_id', contactId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (fieldsRes.data) setCustomFields(fieldsRes.data);
|
||||||
|
if (valuesRes.data) {
|
||||||
|
const map: Record<string, string> = {};
|
||||||
|
valuesRes.data.forEach((v) => {
|
||||||
|
map[v.custom_field_id] = v.value ?? '';
|
||||||
|
});
|
||||||
|
setCustomValues(map);
|
||||||
|
}
|
||||||
|
setLoadingCustom(false);
|
||||||
|
}, [contactId, supabase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && contactId) {
|
||||||
|
fetchContact();
|
||||||
|
fetchTags();
|
||||||
|
fetchNotes();
|
||||||
|
fetchCustomFields();
|
||||||
|
}
|
||||||
|
}, [open, contactId, fetchContact, fetchTags, fetchNotes, fetchCustomFields]);
|
||||||
|
|
||||||
|
async function copyPhone() {
|
||||||
|
if (!contact) return;
|
||||||
|
await navigator.clipboard.writeText(contact.phone);
|
||||||
|
setCopiedPhone(true);
|
||||||
|
setTimeout(() => setCopiedPhone(false), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDetails() {
|
||||||
|
if (!contactId || !editPhone.trim()) {
|
||||||
|
toast.error('Phone number is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSavingDetails(true);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('contacts')
|
||||||
|
.update({
|
||||||
|
name: editName.trim() || null,
|
||||||
|
phone: editPhone.trim(),
|
||||||
|
email: editEmail.trim() || null,
|
||||||
|
company: editCompany.trim() || null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', contactId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error('Failed to update contact');
|
||||||
|
} else {
|
||||||
|
toast.success('Contact updated');
|
||||||
|
fetchContact();
|
||||||
|
onUpdated();
|
||||||
|
}
|
||||||
|
setSavingDetails(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTag(tagId: string) {
|
||||||
|
if (!contactId) return;
|
||||||
|
setSavingTags(true);
|
||||||
|
|
||||||
|
const isSelected = contactTagIds.includes(tagId);
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('contact_tags')
|
||||||
|
.delete()
|
||||||
|
.eq('contact_id', contactId)
|
||||||
|
.eq('tag_id', tagId);
|
||||||
|
if (!error) {
|
||||||
|
setContactTagIds((prev) => prev.filter((id) => id !== tagId));
|
||||||
|
onUpdated();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('contact_tags')
|
||||||
|
.insert({ contact_id: contactId, tag_id: tagId });
|
||||||
|
if (!error) {
|
||||||
|
setContactTagIds((prev) => [...prev, tagId]);
|
||||||
|
onUpdated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSavingTags(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addNote() {
|
||||||
|
if (!contactId || !newNote.trim()) return;
|
||||||
|
setSavingNote(true);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
toast.error('Not authenticated');
|
||||||
|
setSavingNote(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase.from('contact_notes').insert({
|
||||||
|
contact_id: contactId,
|
||||||
|
user_id: user.id,
|
||||||
|
note_text: newNote.trim(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error('Failed to add note');
|
||||||
|
} else {
|
||||||
|
setNewNote('');
|
||||||
|
fetchNotes();
|
||||||
|
toast.success('Note added');
|
||||||
|
}
|
||||||
|
setSavingNote(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteNote(noteId: string) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('contact_notes')
|
||||||
|
.delete()
|
||||||
|
.eq('id', noteId);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
toast.error('Failed to delete note');
|
||||||
|
} else {
|
||||||
|
setNotes((prev) => prev.filter((n) => n.id !== noteId));
|
||||||
|
toast.success('Note deleted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCustomFields() {
|
||||||
|
if (!contactId) return;
|
||||||
|
setSavingCustom(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Delete existing values and re-insert
|
||||||
|
await supabase
|
||||||
|
.from('contact_custom_values')
|
||||||
|
.delete()
|
||||||
|
.eq('contact_id', contactId);
|
||||||
|
|
||||||
|
const rows = Object.entries(customValues)
|
||||||
|
.filter(([, val]) => val.trim())
|
||||||
|
.map(([fieldId, val]) => ({
|
||||||
|
contact_id: contactId,
|
||||||
|
custom_field_id: fieldId,
|
||||||
|
value: val.trim(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (rows.length > 0) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('contact_custom_values')
|
||||||
|
.insert(rows);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Custom fields saved');
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save custom fields');
|
||||||
|
}
|
||||||
|
setSavingCustom(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInitials(name?: string | null) {
|
||||||
|
if (!name) return '?';
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map((w) => w[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent
|
||||||
|
side="right"
|
||||||
|
className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg w-full p-0"
|
||||||
|
>
|
||||||
|
{loading || !contact ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="size-6 animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<SheetHeader className="p-4 border-b border-slate-700/50">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="size-12 bg-slate-800 border border-slate-700">
|
||||||
|
<AvatarFallback className="bg-emerald-500/10 text-emerald-400 text-sm font-medium">
|
||||||
|
{getInitials(contact.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<SheetTitle className="text-white truncate">
|
||||||
|
{contact.name || 'Unknown'}
|
||||||
|
</SheetTitle>
|
||||||
|
<SheetDescription className="text-slate-400 text-xs mt-0.5">
|
||||||
|
Contact details
|
||||||
|
</SheetDescription>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mt-1.5 text-xs text-slate-400">
|
||||||
|
<button
|
||||||
|
onClick={copyPhone}
|
||||||
|
className="flex items-center gap-1 hover:text-emerald-400 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<Phone className="size-3" />
|
||||||
|
{contact.phone}
|
||||||
|
{copiedPhone ? (
|
||||||
|
<Check className="size-3 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{contact.email && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Mail className="size-3" />
|
||||||
|
{contact.email}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{contact.company && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Building2 className="size-3" />
|
||||||
|
{contact.company}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs defaultValue="details" className="flex-1 flex flex-col min-h-0">
|
||||||
|
<TabsList className="bg-slate-800/50 border-b border-slate-700 mx-4 mt-3">
|
||||||
|
<TabsTrigger
|
||||||
|
value="details"
|
||||||
|
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="tags"
|
||||||
|
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||||
|
>
|
||||||
|
Tags
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="notes"
|
||||||
|
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||||
|
>
|
||||||
|
Notes
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="custom"
|
||||||
|
className="data-active:bg-slate-800 data-active:text-emerald-400 text-slate-400"
|
||||||
|
>
|
||||||
|
Custom Fields
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* Details Tab */}
|
||||||
|
<TabsContent value="details" className="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-slate-400 text-xs">Name</Label>
|
||||||
|
<Input
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-slate-400 text-xs">
|
||||||
|
Phone <span className="text-red-400">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={editPhone}
|
||||||
|
onChange={(e) => setEditPhone(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-slate-400 text-xs">Email</Label>
|
||||||
|
<Input
|
||||||
|
value={editEmail}
|
||||||
|
onChange={(e) => setEditEmail(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-slate-400 text-xs">Company</Label>
|
||||||
|
<Input
|
||||||
|
value={editCompany}
|
||||||
|
onChange={(e) => setEditCompany(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white h-8 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={saveDetails}
|
||||||
|
disabled={savingDetails}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white w-full"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{savingDetails ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Tags Tab */}
|
||||||
|
<TabsContent value="tags" className="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Click a tag to add or remove it from this contact.
|
||||||
|
</p>
|
||||||
|
{allTags.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
No tags available. Create tags in Settings.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{allTags.map((tag) => {
|
||||||
|
const selected = contactTagIds.includes(tag.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
onClick={() => toggleTag(tag.id)}
|
||||||
|
disabled={savingTags}
|
||||||
|
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-medium transition-all cursor-pointer ${
|
||||||
|
selected
|
||||||
|
? 'ring-2 ring-emerald-500 ring-offset-1 ring-offset-slate-900'
|
||||||
|
: 'opacity-50 hover:opacity-80'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: tag.color + '20',
|
||||||
|
color: tag.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected && <Check className="size-3 mr-1" />}
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Notes Tab */}
|
||||||
|
<TabsContent value="notes" className="flex-1 flex flex-col min-h-0 px-4 py-3">
|
||||||
|
<div className="space-y-2 mb-3">
|
||||||
|
<Textarea
|
||||||
|
value={newNote}
|
||||||
|
onChange={(e) => setNewNote(e.target.value)}
|
||||||
|
placeholder="Write a note..."
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 min-h-[60px] text-sm resize-none"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={addNote}
|
||||||
|
disabled={!newNote.trim() || savingNote}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{savingNote ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Add Note
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-2">
|
||||||
|
{loadingNotes ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="size-5 animate-spin text-slate-500" />
|
||||||
|
</div>
|
||||||
|
) : notes.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 text-center py-8">
|
||||||
|
No notes yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
notes.map((note) => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className="rounded-lg bg-slate-800/50 border border-slate-700/50 p-3 group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="text-sm text-slate-300 whitespace-pre-wrap flex-1">
|
||||||
|
{note.note_text}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => deleteNote(note.id)}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-slate-500 hover:text-red-400 transition-all cursor-pointer shrink-0"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 mt-1.5">
|
||||||
|
{new Date(note.created_at).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Custom Fields Tab */}
|
||||||
|
<TabsContent value="custom" className="flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
{loadingCustom ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="size-5 animate-spin text-slate-500" />
|
||||||
|
</div>
|
||||||
|
) : customFields.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500 text-center py-8">
|
||||||
|
No custom fields defined. Create them in Settings.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{customFields.map((field) => (
|
||||||
|
<div key={field.id} className="space-y-1.5">
|
||||||
|
<Label className="text-slate-400 text-xs capitalize">
|
||||||
|
{field.field_name}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
value={customValues[field.id] ?? ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCustomValues((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[field.id]: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder={`Enter ${field.field_name}...`}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white h-8 text-sm placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
onClick={saveCustomFields}
|
||||||
|
disabled={savingCustom}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white w-full"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{savingCustom ? (
|
||||||
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="size-3.5" />
|
||||||
|
)}
|
||||||
|
Save Custom Fields
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
286
src/components/contacts/contact-form.tsx
Normal file
286
src/components/contacts/contact-form.tsx
Normal file
@ -0,0 +1,286 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { Contact, Tag, ContactTag } from '@/types';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ContactFormProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
contact?: Contact | null;
|
||||||
|
contactTags?: ContactTag[];
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactForm({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
contact,
|
||||||
|
contactTags = [],
|
||||||
|
onSaved,
|
||||||
|
}: ContactFormProps) {
|
||||||
|
const supabase = createClient();
|
||||||
|
const isEdit = !!contact;
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [company, setCompany] = useState('');
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
|
||||||
|
const [loadingTags, setLoadingTags] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(contact?.name ?? '');
|
||||||
|
setPhone(contact?.phone ?? '');
|
||||||
|
setEmail(contact?.email ?? '');
|
||||||
|
setCompany(contact?.company ?? '');
|
||||||
|
setSelectedTagIds(contactTags.map((ct) => ct.tag_id));
|
||||||
|
fetchTags();
|
||||||
|
}
|
||||||
|
}, [open, contact]);
|
||||||
|
|
||||||
|
async function fetchTags() {
|
||||||
|
setLoadingTags(true);
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('tags')
|
||||||
|
.select('*')
|
||||||
|
.order('name');
|
||||||
|
if (data) setTags(data);
|
||||||
|
setLoadingTags(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTag(tagId: string) {
|
||||||
|
setSelectedTagIds((prev) =>
|
||||||
|
prev.includes(tagId)
|
||||||
|
? prev.filter((id) => id !== tagId)
|
||||||
|
: [...prev, tagId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!phone.trim()) {
|
||||||
|
toast.error('Phone number is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
let contactId = contact?.id;
|
||||||
|
|
||||||
|
if (isEdit && contactId) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('contacts')
|
||||||
|
.update({
|
||||||
|
name: name.trim() || null,
|
||||||
|
phone: phone.trim(),
|
||||||
|
email: email.trim() || null,
|
||||||
|
company: company.trim() || null,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.eq('id', contactId);
|
||||||
|
if (error) throw error;
|
||||||
|
} else {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('contacts')
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
name: name.trim() || null,
|
||||||
|
phone: phone.trim(),
|
||||||
|
email: email.trim() || null,
|
||||||
|
company: company.trim() || null,
|
||||||
|
})
|
||||||
|
.select('id')
|
||||||
|
.single();
|
||||||
|
if (error) throw error;
|
||||||
|
contactId = data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync tags
|
||||||
|
if (contactId) {
|
||||||
|
await supabase
|
||||||
|
.from('contact_tags')
|
||||||
|
.delete()
|
||||||
|
.eq('contact_id', contactId);
|
||||||
|
|
||||||
|
if (selectedTagIds.length > 0) {
|
||||||
|
const tagRows = selectedTagIds.map((tag_id) => ({
|
||||||
|
contact_id: contactId!,
|
||||||
|
tag_id,
|
||||||
|
}));
|
||||||
|
const { error: tagError } = await supabase
|
||||||
|
.from('contact_tags')
|
||||||
|
.insert(tagRows);
|
||||||
|
if (tagError) throw tagError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(isEdit ? 'Contact updated' : 'Contact created');
|
||||||
|
onOpenChange(false);
|
||||||
|
onSaved();
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Failed to save contact';
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">
|
||||||
|
{isEdit ? 'Edit Contact' : 'Add Contact'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
{isEdit
|
||||||
|
? 'Update the contact details below.'
|
||||||
|
: 'Fill in the details to create a new contact.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cf-name" className="text-slate-300">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="cf-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="John Doe"
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cf-phone" className="text-slate-300">
|
||||||
|
Phone <span className="text-red-400">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="cf-phone"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => setPhone(e.target.value)}
|
||||||
|
placeholder="+1 234 567 8900"
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Include country code, e.g. +1 for US
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cf-email" className="text-slate-300">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="cf-email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="john@example.com"
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="cf-company" className="text-slate-300">
|
||||||
|
Company
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="cf-company"
|
||||||
|
value={company}
|
||||||
|
onChange={(e) => setCompany(e.target.value)}
|
||||||
|
placeholder="Acme Inc."
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Tags</Label>
|
||||||
|
{loadingTags ? (
|
||||||
|
<div className="flex items-center gap-2 text-slate-500 text-sm">
|
||||||
|
<Loader2 className="size-3 animate-spin" />
|
||||||
|
Loading tags...
|
||||||
|
</div>
|
||||||
|
) : tags.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
No tags available. Create tags in Settings.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{tags.map((tag) => {
|
||||||
|
const selected = selectedTagIds.includes(tag.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tag.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleTag(tag.id)}
|
||||||
|
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors cursor-pointer ${
|
||||||
|
selected
|
||||||
|
? 'ring-2 ring-emerald-500 ring-offset-1 ring-offset-slate-900'
|
||||||
|
: 'opacity-60 hover:opacity-100'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
backgroundColor: tag.color + '20',
|
||||||
|
color: tag.color,
|
||||||
|
borderColor: tag.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
{isEdit ? 'Update' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
311
src/components/contacts/import-modal.tsx
Normal file
311
src/components/contacts/import-modal.tsx
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Upload, FileText, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ImportModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onImported: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ParsedRow {
|
||||||
|
phone: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
company?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCSV(text: string): ParsedRow[] {
|
||||||
|
const lines = text.trim().split(/\r?\n/);
|
||||||
|
if (lines.length < 2) return [];
|
||||||
|
|
||||||
|
const headerLine = lines[0];
|
||||||
|
const headers = headerLine.split(',').map((h) => h.trim().toLowerCase().replace(/["']/g, ''));
|
||||||
|
|
||||||
|
const phoneIdx = headers.indexOf('phone');
|
||||||
|
if (phoneIdx === -1) return [];
|
||||||
|
|
||||||
|
const nameIdx = headers.indexOf('name');
|
||||||
|
const emailIdx = headers.indexOf('email');
|
||||||
|
const companyIdx = headers.indexOf('company');
|
||||||
|
|
||||||
|
const rows: ParsedRow[] = [];
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
// Simple CSV parse (handles quoted fields)
|
||||||
|
const values: string[] = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
for (const char of line) {
|
||||||
|
if (char === '"') {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
values.push(current.trim());
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
values.push(current.trim());
|
||||||
|
|
||||||
|
const phone = values[phoneIdx]?.replace(/["']/g, '').trim();
|
||||||
|
if (!phone) continue;
|
||||||
|
|
||||||
|
rows.push({
|
||||||
|
phone,
|
||||||
|
name: nameIdx >= 0 ? values[nameIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
|
||||||
|
email: emailIdx >= 0 ? values[emailIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
|
||||||
|
company:
|
||||||
|
companyIdx >= 0 ? values[companyIdx]?.replace(/["']/g, '').trim() || undefined : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportModal({ open, onOpenChange, onImported }: ImportModalProps) {
|
||||||
|
const supabase = createClient();
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [parsedRows, setParsedRows] = useState<ParsedRow[]>([]);
|
||||||
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [result, setResult] = useState<{ imported: number; failed: number } | null>(null);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setFile(null);
|
||||||
|
setParsedRows([]);
|
||||||
|
setResult(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(open: boolean) {
|
||||||
|
if (!open) reset();
|
||||||
|
onOpenChange(open);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const selected = e.target.files?.[0];
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
setFile(selected);
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
const text = await selected.text();
|
||||||
|
const rows = parseCSV(text);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
toast.error('No valid rows found. Ensure CSV has a "phone" column header.');
|
||||||
|
setParsedRows([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setParsedRows(rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImport() {
|
||||||
|
if (parsedRows.length === 0) return;
|
||||||
|
setImporting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) throw new Error('Not authenticated');
|
||||||
|
|
||||||
|
let imported = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
// Batch insert in chunks of 50
|
||||||
|
const chunkSize = 50;
|
||||||
|
for (let i = 0; i < parsedRows.length; i += chunkSize) {
|
||||||
|
const chunk = parsedRows.slice(i, i + chunkSize);
|
||||||
|
const rows = chunk.map((row) => ({
|
||||||
|
user_id: user.id,
|
||||||
|
phone: row.phone,
|
||||||
|
name: row.name || null,
|
||||||
|
email: row.email || null,
|
||||||
|
company: row.company || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('contacts')
|
||||||
|
.insert(rows)
|
||||||
|
.select('id');
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// Try individual inserts for this chunk
|
||||||
|
for (const row of rows) {
|
||||||
|
const { error: singleErr } = await supabase.from('contacts').insert(row);
|
||||||
|
if (singleErr) {
|
||||||
|
failed++;
|
||||||
|
} else {
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
imported += data?.length ?? chunk.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setResult({ imported, failed });
|
||||||
|
if (imported > 0) {
|
||||||
|
toast.success(`${imported} contact${imported !== 1 ? 's' : ''} imported`);
|
||||||
|
onImported();
|
||||||
|
}
|
||||||
|
if (failed > 0) {
|
||||||
|
toast.error(`${failed} contact${failed !== 1 ? 's' : ''} failed to import`);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const message = err instanceof Error ? err.message : 'Import failed';
|
||||||
|
toast.error(message);
|
||||||
|
} finally {
|
||||||
|
setImporting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = parsedRows.slice(0, 5);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-700 text-slate-200 sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">Import Contacts</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
Upload a CSV file with a "phone" column (required). Optional columns:
|
||||||
|
name, email, company.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Upload area */}
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-slate-700 p-6 cursor-pointer hover:border-emerald-500/50 transition-colors"
|
||||||
|
>
|
||||||
|
{file ? (
|
||||||
|
<>
|
||||||
|
<FileText className="size-8 text-emerald-400" />
|
||||||
|
<p className="text-sm text-slate-300">{file.name}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{parsedRows.length} row{parsedRows.length !== 1 ? 's' : ''} detected
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="size-8 text-slate-500" />
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Click to upload CSV file
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
CSV with "phone" column required
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Preview table */}
|
||||||
|
{preview.length > 0 && !result && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs font-medium text-slate-400 uppercase tracking-wider">
|
||||||
|
Preview (first {preview.length} rows)
|
||||||
|
</p>
|
||||||
|
<div className="rounded-lg border border-slate-700 overflow-hidden">
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-800">
|
||||||
|
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Phone</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Name</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Email</th>
|
||||||
|
<th className="px-3 py-1.5 text-left text-slate-400 font-medium">Company</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{preview.map((row, i) => (
|
||||||
|
<tr key={i} className="border-t border-slate-700/50">
|
||||||
|
<td className="px-3 py-1.5 text-slate-300">{row.phone}</td>
|
||||||
|
<td className="px-3 py-1.5 text-slate-300">{row.name || '-'}</td>
|
||||||
|
<td className="px-3 py-1.5 text-slate-300">{row.email || '-'}</td>
|
||||||
|
<td className="px-3 py-1.5 text-slate-300">{row.company || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{parsedRows.length > 5 && (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
...and {parsedRows.length - 5} more rows
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{result && (
|
||||||
|
<div className="rounded-lg border border-slate-700 p-4 space-y-2">
|
||||||
|
<p className="text-sm font-medium text-white">Import Complete</p>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{result.imported > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-emerald-400 text-sm">
|
||||||
|
<CheckCircle className="size-4" />
|
||||||
|
{result.imported} imported
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{result.failed > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-red-400 text-sm">
|
||||||
|
<XCircle className="size-4" />
|
||||||
|
{result.failed} failed
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleOpenChange(false)}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
{result ? 'Close' : 'Cancel'}
|
||||||
|
</Button>
|
||||||
|
{!result && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={parsedRows.length === 0 || importing}
|
||||||
|
onClick={handleImport}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
{importing && <Loader2 className="size-4 animate-spin" />}
|
||||||
|
Import {parsedRows.length > 0 ? `${parsedRows.length} Contacts` : ''}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
288
src/components/inbox/contact-sidebar.tsx
Normal file
288
src/components/inbox/contact-sidebar.tsx
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Contact, Deal, ContactNote, Tag } from "@/types";
|
||||||
|
import {
|
||||||
|
Phone,
|
||||||
|
Mail,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
User,
|
||||||
|
Tag as TagIcon,
|
||||||
|
DollarSign,
|
||||||
|
StickyNote,
|
||||||
|
Plus,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
interface ContactSidebarProps {
|
||||||
|
contact: Contact | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContactSidebar({ contact }: ContactSidebarProps) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const [deals, setDeals] = useState<Deal[]>([]);
|
||||||
|
const [notes, setNotes] = useState<ContactNote[]>([]);
|
||||||
|
const [tags, setTags] = useState<(Tag & { contact_tag_id: string })[]>([]);
|
||||||
|
const [newNote, setNewNote] = useState("");
|
||||||
|
const [addingNote, setAddingNote] = useState(false);
|
||||||
|
|
||||||
|
const fetchContactData = useCallback(async () => {
|
||||||
|
if (!contact) return;
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
// Fetch deals, notes, and tags in parallel
|
||||||
|
const [dealsRes, notesRes, tagsRes] = await Promise.all([
|
||||||
|
supabase
|
||||||
|
.from("deals")
|
||||||
|
.select("*, stage:pipeline_stages(*)")
|
||||||
|
.eq("contact_id", contact.id)
|
||||||
|
.order("created_at", { ascending: false }),
|
||||||
|
supabase
|
||||||
|
.from("contact_notes")
|
||||||
|
.select("*")
|
||||||
|
.eq("contact_id", contact.id)
|
||||||
|
.order("created_at", { ascending: false }),
|
||||||
|
supabase
|
||||||
|
.from("contact_tags")
|
||||||
|
.select("id, tag_id, tags(*)")
|
||||||
|
.eq("contact_id", contact.id),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (dealsRes.data) setDeals(dealsRes.data);
|
||||||
|
if (notesRes.data) setNotes(notesRes.data);
|
||||||
|
if (tagsRes.data) {
|
||||||
|
const mapped = tagsRes.data
|
||||||
|
.filter((ct: Record<string, unknown>) => ct.tags)
|
||||||
|
.map((ct: Record<string, unknown>) => ({
|
||||||
|
...(ct.tags as Tag),
|
||||||
|
contact_tag_id: ct.id as string,
|
||||||
|
}));
|
||||||
|
setTags(mapped);
|
||||||
|
}
|
||||||
|
}, [contact]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchContactData();
|
||||||
|
}, [fetchContactData]);
|
||||||
|
|
||||||
|
const handleCopyPhone = useCallback(async () => {
|
||||||
|
if (!contact?.phone) return;
|
||||||
|
await navigator.clipboard.writeText(contact.phone);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
}, [contact?.phone]);
|
||||||
|
|
||||||
|
const handleAddNote = useCallback(async () => {
|
||||||
|
if (!contact || !newNote.trim()) return;
|
||||||
|
setAddingNote(true);
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("contact_notes")
|
||||||
|
.insert({
|
||||||
|
contact_id: contact.id,
|
||||||
|
user_id: user?.id,
|
||||||
|
note_text: newNote.trim(),
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (!error && data) {
|
||||||
|
setNotes((prev) => [data, ...prev]);
|
||||||
|
setNewNote("");
|
||||||
|
}
|
||||||
|
setAddingNote(false);
|
||||||
|
}, [contact, newNote]);
|
||||||
|
|
||||||
|
if (!contact) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-70 items-center justify-center border-l border-slate-800 bg-slate-900">
|
||||||
|
<p className="text-sm text-slate-500">Select a conversation</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = contact.name || contact.phone;
|
||||||
|
const initials = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-70 flex-col border-l border-slate-800 bg-slate-900">
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Contact Info */}
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-700 text-lg font-semibold text-white">
|
||||||
|
{contact.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={contact.avatar_url}
|
||||||
|
alt={displayName}
|
||||||
|
className="h-16 w-16 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initials
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-3 text-sm font-semibold text-white">
|
||||||
|
{displayName}
|
||||||
|
</h3>
|
||||||
|
{contact.company && (
|
||||||
|
<p className="text-xs text-slate-400">{contact.company}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phone */}
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCopyPhone}
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Phone className="h-4 w-4 text-slate-500" />
|
||||||
|
<span className="flex-1 text-left">{contact.phone}</span>
|
||||||
|
{copied ? (
|
||||||
|
<Check className="h-3 w-3 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3 text-slate-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{contact.email && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-300">
|
||||||
|
<Mail className="h-4 w-4 text-slate-500" />
|
||||||
|
<span className="truncate">{contact.email}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="my-4 border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||||
|
<TagIcon className="h-3 w-3" />
|
||||||
|
Tags
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1">
|
||||||
|
{tags.length === 0 ? (
|
||||||
|
<p className="px-1 text-xs text-slate-600">No tags</p>
|
||||||
|
) : (
|
||||||
|
tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.contact_tag_id}
|
||||||
|
className="rounded-full px-2 py-0.5 text-[10px] font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${tag.color}20`,
|
||||||
|
color: tag.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="my-4 border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* Active Deals */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||||
|
<DollarSign className="h-3 w-3" />
|
||||||
|
Active Deals
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{deals.length === 0 ? (
|
||||||
|
<p className="px-1 text-xs text-slate-600">No deals</p>
|
||||||
|
) : (
|
||||||
|
deals.map((deal) => (
|
||||||
|
<div
|
||||||
|
key={deal.id}
|
||||||
|
className="rounded-lg bg-slate-800 px-3 py-2"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
{deal.title}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>
|
||||||
|
{deal.currency ?? "$"}
|
||||||
|
{deal.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
{deal.stage && (
|
||||||
|
<span
|
||||||
|
className="rounded-full px-1.5 py-0.5 text-[10px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${deal.stage.color}20`,
|
||||||
|
color: deal.stage.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{deal.stage.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="my-4 border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 px-1 text-xs font-medium uppercase tracking-wider text-slate-500">
|
||||||
|
<StickyNote className="h-3 w-3" />
|
||||||
|
Notes
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<textarea
|
||||||
|
value={newNote}
|
||||||
|
onChange={(e) => setNewNote(e.target.value)}
|
||||||
|
placeholder="Add a note..."
|
||||||
|
rows={2}
|
||||||
|
className="flex-1 resize-none rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-xs text-white placeholder-slate-500 outline-none focus:border-emerald-500/50"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-auto bg-emerald-600 px-2 hover:bg-emerald-500"
|
||||||
|
onClick={handleAddNote}
|
||||||
|
disabled={!newNote.trim() || addingNote}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{notes.map((note) => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className="rounded-lg bg-slate-800 px-3 py-2"
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap text-xs text-slate-300">
|
||||||
|
{note.note_text}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-[10px] text-slate-600">
|
||||||
|
{format(new Date(note.created_at), "MMM d, yyyy HH:mm")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
src/components/inbox/conversation-list.tsx
Normal file
250
src/components/inbox/conversation-list.tsx
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Conversation, ConversationStatus } from "@/types";
|
||||||
|
import { Search, ChevronDown } from "lucide-react";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
|
||||||
|
interface ConversationListProps {
|
||||||
|
activeConversationId: string | null;
|
||||||
|
onSelect: (conversation: Conversation) => void;
|
||||||
|
conversations: Conversation[];
|
||||||
|
onConversationsLoaded: (conversations: Conversation[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<ConversationStatus, string> = {
|
||||||
|
open: "bg-emerald-500",
|
||||||
|
pending: "bg-amber-500",
|
||||||
|
closed: "bg-slate-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILTER_OPTIONS: { label: string; value: ConversationStatus | "all" }[] = [
|
||||||
|
{ label: "All", value: "all" },
|
||||||
|
{ label: "Open", value: "open" },
|
||||||
|
{ label: "Pending", value: "pending" },
|
||||||
|
{ label: "Closed", value: "closed" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ConversationList({
|
||||||
|
activeConversationId,
|
||||||
|
onSelect,
|
||||||
|
conversations,
|
||||||
|
onConversationsLoaded,
|
||||||
|
}: ConversationListProps) {
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [filter, setFilter] = useState<ConversationStatus | "all">("all");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const fetchConversations = useCallback(async () => {
|
||||||
|
const supabase = createClient();
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("conversations")
|
||||||
|
.select("*, contact:contacts(*)")
|
||||||
|
.order("last_message_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Failed to fetch conversations:", error);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onConversationsLoaded(data ?? []);
|
||||||
|
setLoading(false);
|
||||||
|
}, [onConversationsLoaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConversations();
|
||||||
|
}, [fetchConversations]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
let result = conversations;
|
||||||
|
|
||||||
|
if (filter !== "all") {
|
||||||
|
result = result.filter((c) => c.status === filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (search.trim()) {
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
result = result.filter((c) => {
|
||||||
|
const name = c.contact?.name?.toLowerCase() ?? "";
|
||||||
|
const phone = c.contact?.phone?.toLowerCase() ?? "";
|
||||||
|
const lastMsg = c.last_message_text?.toLowerCase() ?? "";
|
||||||
|
return name.includes(q) || phone.includes(q) || lastMsg.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [conversations, filter, search]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setSearch(e.target.value);
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(conv: Conversation) => {
|
||||||
|
onSelect(conv);
|
||||||
|
},
|
||||||
|
[onSelect]
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeFilter = FILTER_OPTIONS.find((o) => o.value === filter);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-80 flex-col border-r border-slate-800 bg-slate-900">
|
||||||
|
{/* Search + Filter */}
|
||||||
|
<div className="space-y-2 border-b border-slate-800 p-3">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" />
|
||||||
|
<Input
|
||||||
|
value={search}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
placeholder="Search conversations..."
|
||||||
|
className="border-slate-700 bg-slate-800 pl-9 text-sm text-white placeholder-slate-500 focus:border-emerald-500/50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className="inline-flex items-center justify-center h-7 gap-1 px-2 text-xs text-slate-400 hover:text-white rounded-md hover:bg-slate-800">
|
||||||
|
{activeFilter?.label ?? "All"}
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="start"
|
||||||
|
className="border-slate-700 bg-slate-800"
|
||||||
|
>
|
||||||
|
{FILTER_OPTIONS.map((opt) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setFilter(opt.value)}
|
||||||
|
className={cn(
|
||||||
|
"text-sm",
|
||||||
|
filter === opt.value
|
||||||
|
? "text-emerald-400"
|
||||||
|
: "text-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conversation Items */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : filtered.length === 0 ? (
|
||||||
|
<div className="px-4 py-12 text-center">
|
||||||
|
<p className="text-sm text-slate-500">No conversations found</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
{filtered.map((conv) => (
|
||||||
|
<ConversationItem
|
||||||
|
key={conv.id}
|
||||||
|
conversation={conv}
|
||||||
|
isActive={conv.id === activeConversationId}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConversationItemProps {
|
||||||
|
conversation: Conversation;
|
||||||
|
isActive: boolean;
|
||||||
|
onSelect: (conversation: Conversation) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConversationItem({
|
||||||
|
conversation,
|
||||||
|
isActive,
|
||||||
|
onSelect,
|
||||||
|
}: ConversationItemProps) {
|
||||||
|
const contact = conversation.contact;
|
||||||
|
const displayName = contact?.name || contact?.phone || "Unknown";
|
||||||
|
const initials = displayName.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
onSelect(conversation);
|
||||||
|
}, [onSelect, conversation]);
|
||||||
|
|
||||||
|
const timeAgo = conversation.last_message_at
|
||||||
|
? formatDistanceToNow(new Date(conversation.last_message_at), {
|
||||||
|
addSuffix: false,
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleClick}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-start gap-3 px-3 py-3 text-left transition-colors hover:bg-slate-800/50",
|
||||||
|
isActive && "border-l-2 border-emerald-500 bg-slate-800/70"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
|
||||||
|
{contact?.avatar_url ? (
|
||||||
|
<img
|
||||||
|
src={contact.avatar_url}
|
||||||
|
alt={displayName}
|
||||||
|
className="h-10 w-10 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
initials
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="truncate text-sm font-medium text-white">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10px] text-slate-500">{timeAgo}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 flex items-center justify-between gap-2">
|
||||||
|
<p className="truncate text-xs text-slate-400">
|
||||||
|
{conversation.last_message_text || "No messages yet"}
|
||||||
|
</p>
|
||||||
|
<div className="flex shrink-0 items-center gap-1.5">
|
||||||
|
{conversation.unread_count > 0 && (
|
||||||
|
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-emerald-500 px-1 text-[10px] font-bold text-white">
|
||||||
|
{conversation.unread_count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"h-2 w-2 rounded-full",
|
||||||
|
STATUS_COLORS[conversation.status]
|
||||||
|
)}
|
||||||
|
title={conversation.status}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
src/components/inbox/message-bubble.tsx
Normal file
228
src/components/inbox/message-bubble.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Message } from "@/types";
|
||||||
|
import {
|
||||||
|
Clock,
|
||||||
|
Check,
|
||||||
|
CheckCheck,
|
||||||
|
XCircle,
|
||||||
|
FileText,
|
||||||
|
MapPin,
|
||||||
|
LayoutTemplate,
|
||||||
|
ImageOff,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
interface MessageBubbleProps {
|
||||||
|
message: Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusIcon({ status }: { status: Message["status"] }) {
|
||||||
|
switch (status) {
|
||||||
|
case "sending":
|
||||||
|
return <Clock className="h-3 w-3 text-slate-400" />;
|
||||||
|
case "sent":
|
||||||
|
return <Check className="h-3 w-3 text-slate-400" />;
|
||||||
|
case "delivered":
|
||||||
|
return <CheckCheck className="h-3 w-3 text-slate-400" />;
|
||||||
|
case "read":
|
||||||
|
return <CheckCheck className="h-3 w-3 text-blue-400" />;
|
||||||
|
case "failed":
|
||||||
|
return <XCircle className="h-3 w-3 text-red-400" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function MediaImage({ url, alt }: { url: string; alt: string }) {
|
||||||
|
const [src, setSrc] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const loadImage = useCallback(async () => {
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
// Proxy URLs need auth fetch to create blob URL
|
||||||
|
if (url.startsWith("/api/whatsapp/media/")) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error("Failed to load media");
|
||||||
|
const blob = await res.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
setSrc(blobUrl);
|
||||||
|
} catch {
|
||||||
|
setError(true);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSrc(url);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadImage();
|
||||||
|
return () => {
|
||||||
|
if (src?.startsWith("blob:")) {
|
||||||
|
URL.revokeObjectURL(src);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [loadImage]);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-slate-700">
|
||||||
|
<ImageOff className="h-8 w-8 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-40 w-60 items-center justify-center rounded-lg bg-slate-700">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src ?? ""}
|
||||||
|
alt={alt}
|
||||||
|
className="max-h-64 max-w-60 rounded-lg object-cover"
|
||||||
|
onError={() => setError(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MessageContent({ message }: { message: Message }) {
|
||||||
|
switch (message.content_type) {
|
||||||
|
case "text":
|
||||||
|
return (
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm">
|
||||||
|
{message.content_text}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "image":
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{message.media_url && (
|
||||||
|
<MediaImage url={message.media_url} alt="Shared image" />
|
||||||
|
)}
|
||||||
|
{message.content_text && (
|
||||||
|
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
|
||||||
|
{message.content_text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "video":
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{message.media_url && (
|
||||||
|
<video
|
||||||
|
src={message.media_url}
|
||||||
|
controls
|
||||||
|
className="max-h-64 max-w-60 rounded-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{message.content_text && (
|
||||||
|
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
|
||||||
|
{message.content_text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "audio":
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{message.media_url && (
|
||||||
|
<audio src={message.media_url} controls className="max-w-60" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "document":
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={message.media_url ?? "#"}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-2 rounded-lg bg-slate-700/50 px-3 py-2 text-sm hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
<FileText className="h-5 w-5 shrink-0 text-slate-400" />
|
||||||
|
<span className="truncate">
|
||||||
|
{message.content_text || "Document"}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "template":
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="mb-1 inline-flex items-center gap-1 rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-medium text-emerald-400">
|
||||||
|
<LayoutTemplate className="h-3 w-3" />
|
||||||
|
Template
|
||||||
|
</span>
|
||||||
|
{message.content_text && (
|
||||||
|
<p className="mt-1 whitespace-pre-wrap break-words text-sm">
|
||||||
|
{message.content_text}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "location":
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<MapPin className="h-4 w-4 shrink-0 text-slate-400" />
|
||||||
|
<span>{message.content_text || "Location shared"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<p className="whitespace-pre-wrap break-words text-sm">
|
||||||
|
{message.content_text || "[Unsupported message type]"}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageBubble({ message }: MessageBubbleProps) {
|
||||||
|
const isAgent = message.sender_type === "agent" || message.sender_type === "bot";
|
||||||
|
const time = format(new Date(message.created_at), "HH:mm");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn("flex w-full", isAgent ? "justify-end" : "justify-start")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative max-w-[75%] rounded-2xl px-3 py-2",
|
||||||
|
isAgent
|
||||||
|
? "rounded-br-md bg-emerald-600 text-white"
|
||||||
|
: "rounded-bl-md bg-slate-800 text-slate-100"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageContent message={message} />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"mt-1 flex items-center gap-1",
|
||||||
|
isAgent ? "justify-end" : "justify-start"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] text-white/60">{time}</span>
|
||||||
|
{isAgent && <StatusIcon status={message.status} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/components/inbox/message-composer.tsx
Normal file
131
src/components/inbox/message-composer.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useCallback, KeyboardEvent } from "react";
|
||||||
|
import { Send, LayoutTemplate } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface MessageComposerProps {
|
||||||
|
conversationId: string;
|
||||||
|
sessionExpired: boolean;
|
||||||
|
onSend: (text: string) => void;
|
||||||
|
onOpenTemplates: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MessageComposer({
|
||||||
|
conversationId,
|
||||||
|
sessionExpired,
|
||||||
|
onSend,
|
||||||
|
onOpenTemplates,
|
||||||
|
}: MessageComposerProps) {
|
||||||
|
const [text, setText] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
const adjustHeight = useCallback(() => {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.height = "auto";
|
||||||
|
// Max 4 lines (~96px)
|
||||||
|
el.style.height = `${Math.min(el.scrollHeight, 96)}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSend = useCallback(async () => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (!trimmed || sending || sessionExpired) return;
|
||||||
|
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
onSend(trimmed);
|
||||||
|
setText("");
|
||||||
|
if (textareaRef.current) {
|
||||||
|
textareaRef.current.style.height = "auto";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}, [text, sending, sessionExpired, onSend]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSend();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSend]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleChange = useCallback(
|
||||||
|
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setText(e.target.value);
|
||||||
|
adjustHeight();
|
||||||
|
},
|
||||||
|
[adjustHeight]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-slate-800 bg-slate-900 p-3">
|
||||||
|
{sessionExpired && (
|
||||||
|
<div className="mb-2 flex items-center justify-between rounded-lg bg-amber-500/10 px-3 py-2">
|
||||||
|
<p className="text-xs text-amber-400">
|
||||||
|
24-hour session expired. Use a template to re-engage.
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs text-amber-400 hover:text-amber-300"
|
||||||
|
onClick={onOpenTemplates}
|
||||||
|
>
|
||||||
|
<LayoutTemplate className="mr-1 h-3 w-3" />
|
||||||
|
Templates
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-9 shrink-0 p-0 text-slate-400 hover:text-white"
|
||||||
|
onClick={onOpenTemplates}
|
||||||
|
title="Send template"
|
||||||
|
>
|
||||||
|
<LayoutTemplate className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={text}
|
||||||
|
onChange={handleChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={
|
||||||
|
sessionExpired
|
||||||
|
? "Session expired - use a template"
|
||||||
|
: "Type a message... (Shift+Enter for new line)"
|
||||||
|
}
|
||||||
|
disabled={sessionExpired}
|
||||||
|
rows={1}
|
||||||
|
className={cn(
|
||||||
|
"w-full resize-none rounded-xl border border-slate-700 bg-slate-800 px-4 py-2.5 text-sm text-white placeholder-slate-500 outline-none transition-colors focus:border-emerald-500/50",
|
||||||
|
sessionExpired && "cursor-not-allowed opacity-50"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-slate-600">
|
||||||
|
Type '/' for quick replies
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-9 w-9 shrink-0 bg-emerald-600 p-0 hover:bg-emerald-500 disabled:opacity-40"
|
||||||
|
disabled={!text.trim() || sessionExpired || sending}
|
||||||
|
onClick={handleSend}
|
||||||
|
>
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
330
src/components/inbox/message-thread.tsx
Normal file
330
src/components/inbox/message-thread.tsx
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { Conversation, Message, Contact, ConversationStatus } from "@/types";
|
||||||
|
import {
|
||||||
|
MessageSquare,
|
||||||
|
ChevronDown,
|
||||||
|
UserPlus,
|
||||||
|
Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { format, isToday, isYesterday, differenceInHours } from "date-fns";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { MessageBubble } from "./message-bubble";
|
||||||
|
import { MessageComposer } from "./message-composer";
|
||||||
|
|
||||||
|
interface MessageThreadProps {
|
||||||
|
conversation: Conversation | null;
|
||||||
|
contact: Contact | null;
|
||||||
|
messages: Message[];
|
||||||
|
onMessagesLoaded: (messages: Message[]) => void;
|
||||||
|
onNewMessage: (message: Message) => void;
|
||||||
|
onStatusChange: (conversationId: string, status: ConversationStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateSeparator(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (isToday(date)) return "Today";
|
||||||
|
if (isYesterday(date)) return "Yesterday";
|
||||||
|
return format(date, "MMMM d, yyyy");
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupMessagesByDate(messages: Message[]) {
|
||||||
|
const groups: { date: string; messages: Message[] }[] = [];
|
||||||
|
let currentDate = "";
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
const day = format(new Date(msg.created_at), "yyyy-MM-dd");
|
||||||
|
if (day !== currentDate) {
|
||||||
|
currentDate = day;
|
||||||
|
groups.push({ date: msg.created_at, messages: [msg] });
|
||||||
|
} else {
|
||||||
|
groups[groups.length - 1].messages.push(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { label: string; value: ConversationStatus; color: string }[] = [
|
||||||
|
{ label: "Open", value: "open", color: "text-emerald-400" },
|
||||||
|
{ label: "Pending", value: "pending", color: "text-amber-400" },
|
||||||
|
{ label: "Closed", value: "closed", color: "text-slate-400" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MessageThread({
|
||||||
|
conversation,
|
||||||
|
contact,
|
||||||
|
messages,
|
||||||
|
onMessagesLoaded,
|
||||||
|
onNewMessage,
|
||||||
|
onStatusChange,
|
||||||
|
}: MessageThreadProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||||
|
|
||||||
|
// 24-hour session timer
|
||||||
|
const sessionInfo = useMemo(() => {
|
||||||
|
if (!messages.length) return { expired: false, remaining: "" };
|
||||||
|
|
||||||
|
// Find last customer message
|
||||||
|
const lastCustomerMsg = [...messages]
|
||||||
|
.reverse()
|
||||||
|
.find((m) => m.sender_type === "customer");
|
||||||
|
|
||||||
|
if (!lastCustomerMsg) return { expired: true, remaining: "No customer messages" };
|
||||||
|
|
||||||
|
const hoursSince = differenceInHours(new Date(), new Date(lastCustomerMsg.created_at));
|
||||||
|
const expired = hoursSince >= 24;
|
||||||
|
|
||||||
|
if (expired) {
|
||||||
|
return { expired: true, remaining: "Expired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hoursLeft = 24 - hoursSince;
|
||||||
|
const remaining =
|
||||||
|
hoursLeft >= 1
|
||||||
|
? `${Math.floor(hoursLeft)}h remaining`
|
||||||
|
: `${Math.floor(hoursLeft * 60)}m remaining`;
|
||||||
|
|
||||||
|
return { expired, remaining };
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const fetchMessages = useCallback(async () => {
|
||||||
|
if (!conversation) return;
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("messages")
|
||||||
|
.select("*")
|
||||||
|
.eq("conversation_id", conversation.id)
|
||||||
|
.order("created_at", { ascending: true });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("Failed to fetch messages:", error);
|
||||||
|
} else {
|
||||||
|
onMessagesLoaded(data ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset unread count
|
||||||
|
await supabase
|
||||||
|
.from("conversations")
|
||||||
|
.update({ unread_count: 0 })
|
||||||
|
.eq("id", conversation.id);
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
}, [conversation, onMessagesLoaded]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMessages();
|
||||||
|
}, [fetchMessages]);
|
||||||
|
|
||||||
|
// Auto-scroll to bottom on new messages
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
const el = scrollRef.current;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSend = useCallback(
|
||||||
|
async (text: string) => {
|
||||||
|
if (!conversation) return;
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
const optimisticMsg: Message = {
|
||||||
|
id: `temp-${Date.now()}`,
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
sender_type: "agent",
|
||||||
|
content_type: "text",
|
||||||
|
content_text: text,
|
||||||
|
status: "sending",
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
onNewMessage(optimisticMsg);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/whatsapp/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
conversation_id: conversation.id,
|
||||||
|
message_type: "text",
|
||||||
|
content_text: text,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error("Failed to send message");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send message:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[conversation, onNewMessage]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleStatusChange = useCallback(
|
||||||
|
async (status: ConversationStatus) => {
|
||||||
|
if (!conversation) return;
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
await supabase
|
||||||
|
.from("conversations")
|
||||||
|
.update({ status })
|
||||||
|
.eq("id", conversation.id);
|
||||||
|
|
||||||
|
onStatusChange(conversation.id, status);
|
||||||
|
},
|
||||||
|
[conversation, onStatusChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenTemplates = useCallback(() => {
|
||||||
|
setTemplateModalOpen(true);
|
||||||
|
// Template modal implementation would go here
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if (!conversation || !contact) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center bg-slate-950">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-slate-800">
|
||||||
|
<MessageSquare className="h-8 w-8 text-slate-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-sm font-medium text-slate-400">
|
||||||
|
Select a conversation
|
||||||
|
</h3>
|
||||||
|
<p className="mt-1 text-xs text-slate-600">
|
||||||
|
Choose a conversation from the left to start messaging
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = contact.name || contact.phone;
|
||||||
|
const messageGroups = groupMessagesByDate(messages);
|
||||||
|
const currentStatus = STATUS_OPTIONS.find(
|
||||||
|
(s) => s.value === conversation.status
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 flex-col bg-slate-950">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-slate-700 text-sm font-medium text-white">
|
||||||
|
{displayName.charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">{displayName}</h2>
|
||||||
|
<p className="text-xs text-slate-400">{contact.phone}</p>
|
||||||
|
</div>
|
||||||
|
{/* Session timer badge */}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"ml-2 gap-1 border-slate-700 text-[10px]",
|
||||||
|
sessionInfo.expired ? "text-red-400" : "text-emerald-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{sessionInfo.remaining}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Status dropdown */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger className={cn(
|
||||||
|
"inline-flex items-center justify-center h-7 gap-1 px-2 text-xs rounded-md hover:bg-slate-800",
|
||||||
|
currentStatus?.color ?? "text-slate-400"
|
||||||
|
)}>
|
||||||
|
{currentStatus?.label ?? "Status"}
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
align="end"
|
||||||
|
className="border-slate-700 bg-slate-800"
|
||||||
|
>
|
||||||
|
{STATUS_OPTIONS.map((opt) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => handleStatusChange(opt.value)}
|
||||||
|
className={cn("text-sm", opt.color)}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* Assign button */}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 text-xs text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<UserPlus className="h-3 w-3" />
|
||||||
|
Assign
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages Area */}
|
||||||
|
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<p className="text-sm text-slate-500">No messages yet</p>
|
||||||
|
<p className="text-xs text-slate-600">
|
||||||
|
Send a template to start the conversation
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{messageGroups.map((group) => (
|
||||||
|
<div key={group.date}>
|
||||||
|
{/* Date separator */}
|
||||||
|
<div className="mb-4 flex items-center justify-center">
|
||||||
|
<span className="rounded-full bg-slate-800 px-3 py-1 text-[10px] font-medium text-slate-400">
|
||||||
|
{formatDateSeparator(group.date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{group.messages.map((msg) => (
|
||||||
|
<MessageBubble key={msg.id} message={msg} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<MessageComposer
|
||||||
|
conversationId={conversation.id}
|
||||||
|
sessionExpired={sessionInfo.expired}
|
||||||
|
onSend={handleSend}
|
||||||
|
onOpenTemplates={handleOpenTemplates}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
src/components/layout/header.tsx
Normal file
56
src/components/layout/header.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
|
||||||
|
const pageTitles: Record<string, string> = {
|
||||||
|
"/dashboard": "Dashboard",
|
||||||
|
"/dashboard/inbox": "Inbox",
|
||||||
|
"/dashboard/contacts": "Contacts",
|
||||||
|
"/dashboard/pipelines": "Pipelines",
|
||||||
|
"/dashboard/broadcasts": "Broadcasts",
|
||||||
|
"/dashboard/automations": "Automations",
|
||||||
|
"/dashboard/settings": "Settings",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPageTitle(pathname: string): string {
|
||||||
|
if (pageTitles[pathname]) return pageTitles[pathname];
|
||||||
|
|
||||||
|
const match = Object.entries(pageTitles).find(([path]) =>
|
||||||
|
pathname.startsWith(path)
|
||||||
|
);
|
||||||
|
return match ? match[1] : "Dashboard";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { profile, signOut } = useAuth();
|
||||||
|
const title = getPageTitle(pathname);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-14 shrink-0 items-center justify-between border-b border-slate-800 bg-slate-950 px-6">
|
||||||
|
<h1 className="text-lg font-semibold text-white">{title}</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
||||||
|
{profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"}
|
||||||
|
</div>
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<p className="text-sm font-medium text-white">
|
||||||
|
{profile?.full_name ?? "User"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={signOut}
|
||||||
|
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
src/components/layout/sidebar.tsx
Normal file
125
src/components/layout/sidebar.tsx
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
MessageSquare,
|
||||||
|
Users,
|
||||||
|
GitBranch,
|
||||||
|
Radio,
|
||||||
|
Zap,
|
||||||
|
Settings,
|
||||||
|
LogOut,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
{ href: "/dashboard/inbox", label: "Inbox", icon: MessageSquare },
|
||||||
|
{ href: "/dashboard/contacts", label: "Contacts", icon: Users },
|
||||||
|
{ href: "/dashboard/pipelines", label: "Pipelines", icon: GitBranch },
|
||||||
|
{ href: "/dashboard/broadcasts", label: "Broadcasts", icon: Radio },
|
||||||
|
{ href: "/dashboard/automations", label: "Automations", icon: Zap },
|
||||||
|
];
|
||||||
|
|
||||||
|
const bottomNavItems = [
|
||||||
|
{ href: "/dashboard/settings", label: "Settings", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function Sidebar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { profile, signOut } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="flex h-screen w-60 flex-col border-r border-slate-800 bg-slate-900">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="flex h-14 items-center gap-2 border-b border-slate-800 px-4">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-emerald-500">
|
||||||
|
<MessageSquare className="h-4 w-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="text-lg font-semibold text-white">WaCRM</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main navigation */}
|
||||||
|
<nav className="flex-1 overflow-y-auto px-3 py-4">
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const isActive =
|
||||||
|
pathname === item.href ||
|
||||||
|
(item.href !== "/dashboard" && pathname.startsWith(item.href));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-emerald-500/10 text-emerald-500"
|
||||||
|
: "text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="my-4 border-t border-slate-800" />
|
||||||
|
|
||||||
|
{/* Bottom nav items */}
|
||||||
|
<ul className="flex flex-col gap-1">
|
||||||
|
{bottomNavItems.map((item) => {
|
||||||
|
const isActive = pathname.startsWith(item.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
||||||
|
isActive
|
||||||
|
? "bg-emerald-500/10 text-emerald-500"
|
||||||
|
: "text-slate-400 hover:bg-slate-800 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<item.icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* User section */}
|
||||||
|
<div className="border-t border-slate-800 p-3">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg px-3 py-2">
|
||||||
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-500/10 text-sm font-medium text-emerald-500">
|
||||||
|
{profile?.full_name?.charAt(0)?.toUpperCase() ?? "U"}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-sm font-medium text-white">
|
||||||
|
{profile?.full_name ?? "User"}
|
||||||
|
</p>
|
||||||
|
<p className="truncate text-xs text-slate-400">
|
||||||
|
{profile?.email ?? ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={signOut}
|
||||||
|
className="rounded-md p-1.5 text-slate-400 transition-colors hover:bg-slate-800 hover:text-white"
|
||||||
|
title="Sign out"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/components/pipelines/deal-card.tsx
Normal file
123
src/components/pipelines/deal-card.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Deal } from "@/types";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Calendar,
|
||||||
|
User,
|
||||||
|
DollarSign,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
function formatCurrency(value: number, currency?: string) {
|
||||||
|
const cur = currency || "USD";
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: cur,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DealCardProps {
|
||||||
|
deal: Deal;
|
||||||
|
onEdit: (deal: Deal) => void;
|
||||||
|
onMoveStage: (dealId: string, direction: "prev" | "next") => void;
|
||||||
|
hasPrev: boolean;
|
||||||
|
hasNext: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DealCard({
|
||||||
|
deal,
|
||||||
|
onEdit,
|
||||||
|
onMoveStage,
|
||||||
|
hasPrev,
|
||||||
|
hasNext,
|
||||||
|
}: DealCardProps) {
|
||||||
|
const statusColor =
|
||||||
|
deal.status === "won"
|
||||||
|
? "text-emerald-400"
|
||||||
|
: deal.status === "lost"
|
||||||
|
? "text-red-400"
|
||||||
|
: "text-slate-400";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={() => onEdit(deal)}
|
||||||
|
className="group cursor-pointer rounded-lg border border-slate-700/50 bg-slate-800/50 p-3 transition-all hover:border-slate-600 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-start justify-between gap-2">
|
||||||
|
<h4 className="text-sm font-medium text-white leading-snug truncate">
|
||||||
|
{deal.title}
|
||||||
|
</h4>
|
||||||
|
{deal.status && deal.status !== "open" && (
|
||||||
|
<Badge variant="outline" className={statusColor}>
|
||||||
|
{deal.status}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
|
<DollarSign className="h-3.5 w-3.5 text-emerald-400" />
|
||||||
|
<span className="text-sm font-semibold text-emerald-400">
|
||||||
|
{formatCurrency(deal.value, deal.currency)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deal.contact && (
|
||||||
|
<div className="mb-1.5 flex items-center gap-1.5">
|
||||||
|
<User className="h-3 w-3 text-slate-500" />
|
||||||
|
<span className="text-xs text-slate-400 truncate">
|
||||||
|
{deal.contact.name || deal.contact.phone}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{deal.expected_close_date && (
|
||||||
|
<div className="mb-2 flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3 text-slate-500" />
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
{formatDate(deal.expected_close_date)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
disabled={!hasPrev}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMoveStage(deal.id, "prev");
|
||||||
|
}}
|
||||||
|
className="text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
disabled={!hasNext}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onMoveStage(deal.id, "next");
|
||||||
|
}}
|
||||||
|
className="text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
229
src/components/pipelines/deal-form.tsx
Normal file
229
src/components/pipelines/deal-form.tsx
Normal file
@ -0,0 +1,229 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { Deal, Contact, PipelineStage } from "@/types";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
|
||||||
|
interface DealFormProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
deal?: Deal | null;
|
||||||
|
pipelineId: string;
|
||||||
|
stages: PipelineStage[];
|
||||||
|
defaultStageId?: string;
|
||||||
|
onSaved: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DealForm({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
deal,
|
||||||
|
pipelineId,
|
||||||
|
stages,
|
||||||
|
defaultStageId,
|
||||||
|
onSaved,
|
||||||
|
}: DealFormProps) {
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [value, setValue] = useState("");
|
||||||
|
const [currency, setCurrency] = useState("USD");
|
||||||
|
const [contactId, setContactId] = useState("");
|
||||||
|
const [stageId, setStageId] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [expectedCloseDate, setExpectedCloseDate] = useState("");
|
||||||
|
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadContacts();
|
||||||
|
if (deal) {
|
||||||
|
setTitle(deal.title);
|
||||||
|
setValue(String(deal.value));
|
||||||
|
setCurrency(deal.currency || "USD");
|
||||||
|
setContactId(deal.contact_id);
|
||||||
|
setStageId(deal.stage_id);
|
||||||
|
setNotes(deal.notes || "");
|
||||||
|
setExpectedCloseDate(deal.expected_close_date || "");
|
||||||
|
} else {
|
||||||
|
setTitle("");
|
||||||
|
setValue("");
|
||||||
|
setCurrency("USD");
|
||||||
|
setContactId("");
|
||||||
|
setStageId(defaultStageId || (stages[0]?.id ?? ""));
|
||||||
|
setNotes("");
|
||||||
|
setExpectedCloseDate("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [open, deal, defaultStageId, stages]);
|
||||||
|
|
||||||
|
async function loadContacts() {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("contacts")
|
||||||
|
.select("*")
|
||||||
|
.order("name");
|
||||||
|
if (data) setContacts(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!title.trim() || !contactId || !stageId) return;
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
title: title.trim(),
|
||||||
|
value: parseFloat(value) || 0,
|
||||||
|
currency,
|
||||||
|
contact_id: contactId,
|
||||||
|
pipeline_id: pipelineId,
|
||||||
|
stage_id: stageId,
|
||||||
|
notes: notes.trim() || null,
|
||||||
|
expected_close_date: expectedCloseDate || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (deal) {
|
||||||
|
await supabase.from("deals").update(payload).eq("id", deal.id);
|
||||||
|
} else {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (user) {
|
||||||
|
await supabase
|
||||||
|
.from("deals")
|
||||||
|
.insert({ ...payload, user_id: user.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
onSaved();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">
|
||||||
|
{deal ? "Edit Deal" : "New Deal"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-slate-300">Title</Label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="Deal title"
|
||||||
|
className="bg-slate-800 border-slate-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-slate-300">Value</Label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="0"
|
||||||
|
className="bg-slate-800 border-slate-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-slate-300">Currency</Label>
|
||||||
|
<select
|
||||||
|
value={currency}
|
||||||
|
onChange={(e) => setCurrency(e.target.value)}
|
||||||
|
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
<option value="USD">USD</option>
|
||||||
|
<option value="EUR">EUR</option>
|
||||||
|
<option value="GBP">GBP</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-slate-300">Contact</Label>
|
||||||
|
<select
|
||||||
|
value={contactId}
|
||||||
|
onChange={(e) => setContactId(e.target.value)}
|
||||||
|
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
<option value="">Select a contact</option>
|
||||||
|
{contacts.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name || c.phone}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-slate-300">Stage</Label>
|
||||||
|
<select
|
||||||
|
value={stageId}
|
||||||
|
onChange={(e) => setStageId(e.target.value)}
|
||||||
|
className="h-8 w-full rounded-lg border border-slate-700 bg-slate-800 px-2.5 text-sm text-white outline-none focus:border-emerald-500 focus:ring-1 focus:ring-emerald-500"
|
||||||
|
>
|
||||||
|
{stages.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-slate-300">Expected Close Date</Label>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={expectedCloseDate}
|
||||||
|
onChange={(e) => setExpectedCloseDate(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-slate-300">Notes</Label>
|
||||||
|
<Textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Add notes..."
|
||||||
|
className="bg-slate-800 border-slate-700 text-white min-h-[80px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !title.trim() || !contactId}
|
||||||
|
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : deal ? "Update Deal" : "Create Deal"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
200
src/components/pipelines/pipeline-board.tsx
Normal file
200
src/components/pipelines/pipeline-board.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { PipelineStage, Deal } from "@/types";
|
||||||
|
import { DealCard } from "./deal-card";
|
||||||
|
import { DealForm } from "./deal-form";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Plus, DollarSign } from "lucide-react";
|
||||||
|
|
||||||
|
function formatCurrency(value: number) {
|
||||||
|
return new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PipelineBoardProps {
|
||||||
|
pipelineId: string;
|
||||||
|
stages: PipelineStage[];
|
||||||
|
onStagesChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PipelineBoard({
|
||||||
|
pipelineId,
|
||||||
|
stages,
|
||||||
|
onStagesChange,
|
||||||
|
}: PipelineBoardProps) {
|
||||||
|
const [deals, setDeals] = useState<Deal[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [dealFormOpen, setDealFormOpen] = useState(false);
|
||||||
|
const [editingDeal, setEditingDeal] = useState<Deal | null>(null);
|
||||||
|
const [defaultStageId, setDefaultStageId] = useState<string>("");
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
const sortedStages = [...stages].sort((a, b) => a.position - b.position);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (pipelineId) {
|
||||||
|
loadDeals();
|
||||||
|
}
|
||||||
|
}, [pipelineId]);
|
||||||
|
|
||||||
|
async function loadDeals() {
|
||||||
|
setLoading(true);
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("deals")
|
||||||
|
.select("*, contact:contacts(*)")
|
||||||
|
.eq("pipeline_id", pipelineId)
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
if (data) setDeals(data);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDealsForStage(stageId: string) {
|
||||||
|
return deals.filter((d) => d.stage_id === stageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTotalValue(stageId: string) {
|
||||||
|
return getDealsForStage(stageId).reduce((sum, d) => sum + d.value, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddDeal(stageId: string) {
|
||||||
|
setEditingDeal(null);
|
||||||
|
setDefaultStageId(stageId);
|
||||||
|
setDealFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditDeal(deal: Deal) {
|
||||||
|
setEditingDeal(deal);
|
||||||
|
setDefaultStageId(deal.stage_id);
|
||||||
|
setDealFormOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMoveStage(dealId: string, direction: "prev" | "next") {
|
||||||
|
const deal = deals.find((d) => d.id === dealId);
|
||||||
|
if (!deal) return;
|
||||||
|
|
||||||
|
const currentIndex = sortedStages.findIndex(
|
||||||
|
(s) => s.id === deal.stage_id
|
||||||
|
);
|
||||||
|
const targetIndex =
|
||||||
|
direction === "prev" ? currentIndex - 1 : currentIndex + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= sortedStages.length) return;
|
||||||
|
|
||||||
|
const targetStage = sortedStages[targetIndex];
|
||||||
|
await supabase
|
||||||
|
.from("deals")
|
||||||
|
.update({ stage_id: targetStage.id })
|
||||||
|
.eq("id", dealId);
|
||||||
|
|
||||||
|
setDeals(
|
||||||
|
deals.map((d) =>
|
||||||
|
d.id === dealId ? { ...d, stage_id: targetStage.id } : d
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
|
{sortedStages.map((stage) => (
|
||||||
|
<div
|
||||||
|
key={stage.id}
|
||||||
|
className="flex w-72 shrink-0 flex-col rounded-xl border border-slate-800 bg-slate-900/50"
|
||||||
|
>
|
||||||
|
<div className="p-3 border-b border-slate-800">
|
||||||
|
<div className="h-4 w-24 animate-pulse rounded bg-slate-800" />
|
||||||
|
<div className="mt-2 h-3 w-16 animate-pulse rounded bg-slate-800" />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{[1, 2].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="h-24 animate-pulse rounded-lg bg-slate-800/50"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4 min-h-[calc(100vh-14rem)]">
|
||||||
|
{sortedStages.map((stage, stageIndex) => {
|
||||||
|
const stageDeals = getDealsForStage(stage.id);
|
||||||
|
const totalValue = getTotalValue(stage.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={stage.id}
|
||||||
|
className="flex w-72 shrink-0 flex-col rounded-xl border border-slate-800 bg-slate-900/50"
|
||||||
|
>
|
||||||
|
{/* Column header */}
|
||||||
|
<div className="p-3 border-b border-slate-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="h-2.5 w-2.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: stage.color }}
|
||||||
|
/>
|
||||||
|
<h3 className="text-sm font-medium text-white truncate">
|
||||||
|
{stage.name}
|
||||||
|
</h3>
|
||||||
|
<span className="ml-auto shrink-0 flex h-5 w-5 items-center justify-center rounded-full bg-slate-800 text-xs text-slate-400">
|
||||||
|
{stageDeals.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{totalValue > 0 && (
|
||||||
|
<div className="mt-1.5 flex items-center gap-1 text-xs text-slate-400">
|
||||||
|
<DollarSign className="h-3 w-3" />
|
||||||
|
{formatCurrency(totalValue)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Deal cards */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||||
|
{stageDeals.map((deal) => (
|
||||||
|
<DealCard
|
||||||
|
key={deal.id}
|
||||||
|
deal={deal}
|
||||||
|
onEdit={handleEditDeal}
|
||||||
|
onMoveStage={handleMoveStage}
|
||||||
|
hasPrev={stageIndex > 0}
|
||||||
|
hasNext={stageIndex < sortedStages.length - 1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAddDeal(stage.id)}
|
||||||
|
className="w-full justify-start text-slate-400 hover:text-white border border-dashed border-slate-700 hover:border-slate-600"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
Add Deal
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DealForm
|
||||||
|
open={dealFormOpen}
|
||||||
|
onOpenChange={setDealFormOpen}
|
||||||
|
deal={editingDeal}
|
||||||
|
pipelineId={pipelineId}
|
||||||
|
stages={sortedStages}
|
||||||
|
defaultStageId={defaultStageId}
|
||||||
|
onSaved={loadDeals}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
326
src/components/pipelines/pipeline-settings.tsx
Normal file
326
src/components/pipelines/pipeline-settings.tsx
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { Pipeline, PipelineStage } from "@/types";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
Trash2,
|
||||||
|
Plus,
|
||||||
|
GripVertical,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
AlertTriangle,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const STAGE_COLORS = [
|
||||||
|
"#6366f1",
|
||||||
|
"#8b5cf6",
|
||||||
|
"#ec4899",
|
||||||
|
"#f43f5e",
|
||||||
|
"#f97316",
|
||||||
|
"#eab308",
|
||||||
|
"#22c55e",
|
||||||
|
"#14b8a6",
|
||||||
|
"#06b6d4",
|
||||||
|
"#3b82f6",
|
||||||
|
];
|
||||||
|
|
||||||
|
interface PipelineSettingsProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
pipeline: Pipeline;
|
||||||
|
stages: PipelineStage[];
|
||||||
|
onUpdated: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PipelineSettings({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
pipeline,
|
||||||
|
stages,
|
||||||
|
onUpdated,
|
||||||
|
}: PipelineSettingsProps) {
|
||||||
|
const [name, setName] = useState(pipeline.name);
|
||||||
|
const [localStages, setLocalStages] = useState<PipelineStage[]>(stages);
|
||||||
|
const [newStageName, setNewStageName] = useState("");
|
||||||
|
const [newStageColor, setNewStageColor] = useState(STAGE_COLORS[0]);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setName(pipeline.name);
|
||||||
|
setLocalStages([...stages].sort((a, b) => a.position - b.position));
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
}
|
||||||
|
}, [open, pipeline, stages]);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from("pipelines")
|
||||||
|
.update({ name: name.trim() })
|
||||||
|
.eq("id", pipeline.id);
|
||||||
|
|
||||||
|
for (let i = 0; i < localStages.length; i++) {
|
||||||
|
const stage = localStages[i];
|
||||||
|
await supabase
|
||||||
|
.from("pipeline_stages")
|
||||||
|
.update({ name: stage.name, color: stage.color, position: i })
|
||||||
|
.eq("id", stage.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
onUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddStage() {
|
||||||
|
if (!newStageName.trim()) return;
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("pipeline_stages")
|
||||||
|
.insert({
|
||||||
|
pipeline_id: pipeline.id,
|
||||||
|
name: newStageName.trim(),
|
||||||
|
color: newStageColor,
|
||||||
|
position: localStages.length,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setLocalStages([...localStages, data]);
|
||||||
|
setNewStageName("");
|
||||||
|
setNewStageColor(
|
||||||
|
STAGE_COLORS[(localStages.length + 1) % STAGE_COLORS.length]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveStage(stageId: string) {
|
||||||
|
await supabase.from("pipeline_stages").delete().eq("id", stageId);
|
||||||
|
setLocalStages(localStages.filter((s) => s.id !== stageId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveStage(index: number, direction: "up" | "down") {
|
||||||
|
const newStages = [...localStages];
|
||||||
|
const targetIndex = direction === "up" ? index - 1 : index + 1;
|
||||||
|
if (targetIndex < 0 || targetIndex >= newStages.length) return;
|
||||||
|
[newStages[index], newStages[targetIndex]] = [
|
||||||
|
newStages[targetIndex],
|
||||||
|
newStages[index],
|
||||||
|
];
|
||||||
|
setLocalStages(newStages);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeletePipeline() {
|
||||||
|
setDeleting(true);
|
||||||
|
await supabase.from("deals").delete().eq("pipeline_id", pipeline.id);
|
||||||
|
await supabase
|
||||||
|
.from("pipeline_stages")
|
||||||
|
.delete()
|
||||||
|
.eq("pipeline_id", pipeline.id);
|
||||||
|
await supabase.from("pipelines").delete().eq("id", pipeline.id);
|
||||||
|
setDeleting(false);
|
||||||
|
onOpenChange(false);
|
||||||
|
onUpdated();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-md bg-slate-900 border-slate-700">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">Pipeline Settings</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{showDeleteConfirm ? (
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="flex items-center gap-3 rounded-lg border border-red-500/30 bg-red-500/10 p-4">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-red-400 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-red-400">
|
||||||
|
Delete Pipeline
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-400">
|
||||||
|
This will permanently delete the pipeline, all stages, and all
|
||||||
|
deals within it. This cannot be undone.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="border-slate-700 text-slate-300"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeletePipeline}
|
||||||
|
disabled={deleting}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700"
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting..." : "Delete Pipeline"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid gap-4 py-2">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-slate-300">Pipeline Name</Label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label className="text-slate-300">Stages</Label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{localStages.map((stage, index) => (
|
||||||
|
<div
|
||||||
|
key={stage.id}
|
||||||
|
className="flex items-center gap-2 rounded-lg border border-slate-700 bg-slate-800 p-2"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-slate-500 shrink-0" />
|
||||||
|
<div
|
||||||
|
className="h-4 w-4 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: stage.color }}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
value={stage.name}
|
||||||
|
onChange={(e) => {
|
||||||
|
const updated = [...localStages];
|
||||||
|
updated[index] = {
|
||||||
|
...updated[index],
|
||||||
|
name: e.target.value,
|
||||||
|
};
|
||||||
|
setLocalStages(updated);
|
||||||
|
}}
|
||||||
|
className="h-7 bg-transparent border-transparent text-white text-sm focus:border-slate-600"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => moveStage(index, "up")}
|
||||||
|
className="text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<ArrowUp className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
disabled={index === localStages.length - 1}
|
||||||
|
onClick={() => moveStage(index, "down")}
|
||||||
|
className="text-slate-400 hover:text-white"
|
||||||
|
>
|
||||||
|
<ArrowDown className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={() => handleRemoveStage(stage.id)}
|
||||||
|
className="text-slate-400 hover:text-red-400"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{STAGE_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color}
|
||||||
|
onClick={() => setNewStageColor(color)}
|
||||||
|
className="h-5 w-5 rounded-full border-2 transition-transform hover:scale-110"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color,
|
||||||
|
borderColor:
|
||||||
|
newStageColor === color
|
||||||
|
? "white"
|
||||||
|
: "transparent",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={newStageName}
|
||||||
|
onChange={(e) => setNewStageName(e.target.value)}
|
||||||
|
placeholder="New stage name"
|
||||||
|
className="bg-slate-800 border-slate-700 text-white text-sm"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") handleAddStage();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAddStage}
|
||||||
|
disabled={!newStageName.trim()}
|
||||||
|
className="border-slate-700 text-slate-300 shrink-0"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="bg-slate-900/50 border-slate-700">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="mr-auto"
|
||||||
|
>
|
||||||
|
Delete Pipeline
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || !name.trim()}
|
||||||
|
className="bg-emerald-600 text-white hover:bg-emerald-700"
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
331
src/components/settings/tag-manager.tsx
Normal file
331
src/components/settings/tag-manager.tsx
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Plus, X, Loader2 } from 'lucide-react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import type { Tag } from '@/types';
|
||||||
|
|
||||||
|
const PRESET_COLORS = [
|
||||||
|
{ name: 'Red', value: '#ef4444' },
|
||||||
|
{ name: 'Orange', value: '#f97316' },
|
||||||
|
{ name: 'Amber', value: '#f59e0b' },
|
||||||
|
{ name: 'Emerald', value: '#10b981' },
|
||||||
|
{ name: 'Cyan', value: '#06b6d4' },
|
||||||
|
{ name: 'Blue', value: '#3b82f6' },
|
||||||
|
{ name: 'Violet', value: '#8b5cf6' },
|
||||||
|
{ name: 'Pink', value: '#ec4899' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function TagManager() {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [tagToDelete, setTagToDelete] = useState<Tag | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [newTagName, setNewTagName] = useState('');
|
||||||
|
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[3].value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTags();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchTags() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('tags')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('created_at', { ascending: true });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setTags(data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch tags:', err);
|
||||||
|
toast.error('Failed to load tags');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCreate() {
|
||||||
|
if (!newTagName.trim()) {
|
||||||
|
toast.error('Tag name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
toast.error('Not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('tags')
|
||||||
|
.insert({
|
||||||
|
user_id: user.id,
|
||||||
|
name: newTagName.trim(),
|
||||||
|
color: selectedColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
toast.success('Tag created successfully');
|
||||||
|
setDialogOpen(false);
|
||||||
|
setNewTagName('');
|
||||||
|
setSelectedColor(PRESET_COLORS[3].value);
|
||||||
|
await fetchTags();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Create error:', err);
|
||||||
|
toast.error('Failed to create tag');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(tag: Tag) {
|
||||||
|
setTagToDelete(tag);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!tagToDelete) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setDeleting(true);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('tags')
|
||||||
|
.delete()
|
||||||
|
.eq('id', tagToDelete.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
toast.success('Tag deleted');
|
||||||
|
setTags((prev) => prev.filter((t) => t.id !== tagToDelete.id));
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setTagToDelete(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete error:', err);
|
||||||
|
toast.error('Failed to delete tag');
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="size-6 animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Tags</h2>
|
||||||
|
<p className="text-sm text-slate-400">Organize your contacts with color-coded tags.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setNewTagName('');
|
||||||
|
setSelectedColor(PRESET_COLORS[3].value);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Tag
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tags.length === 0 ? (
|
||||||
|
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<p className="text-slate-400 text-sm">No tags yet.</p>
|
||||||
|
<p className="text-slate-500 text-xs mt-1">Create tags to categorize your contacts.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||||
|
<CardContent className="pt-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag.id}
|
||||||
|
className="group inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${tag.color}20`,
|
||||||
|
color: tag.color,
|
||||||
|
border: `1px solid ${tag.color}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full"
|
||||||
|
style={{ backgroundColor: tag.color }}
|
||||||
|
/>
|
||||||
|
{tag.name}
|
||||||
|
<button
|
||||||
|
onClick={() => confirmDelete(tag)}
|
||||||
|
className="ml-0.5 rounded-full p-0.5 opacity-0 group-hover:opacity-100 transition-opacity hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New Tag Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">New Tag</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
Create a new tag with a name and color.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Tag Name</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. VIP Customer"
|
||||||
|
value={newTagName}
|
||||||
|
onChange={(e) => setNewTagName(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleCreate();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Color</Label>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{PRESET_COLORS.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.value}
|
||||||
|
onClick={() => setSelectedColor(color.value)}
|
||||||
|
className="relative size-8 rounded-full transition-transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-slate-900"
|
||||||
|
style={{
|
||||||
|
backgroundColor: color.value,
|
||||||
|
boxShadow: selectedColor === color.value ? `0 0 0 2px rgb(15 23 42), 0 0 0 4px ${color.value}` : 'none',
|
||||||
|
}}
|
||||||
|
title={color.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Preview</Label>
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 text-sm font-medium"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${selectedColor}20`,
|
||||||
|
color: selectedColor,
|
||||||
|
border: `1px solid ${selectedColor}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="size-2 rounded-full"
|
||||||
|
style={{ backgroundColor: selectedColor }}
|
||||||
|
/>
|
||||||
|
{newTagName || 'Tag Name'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create Tag'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">Delete Tag</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
Are you sure you want to delete the tag "{tagToDelete?.name}"? This will remove
|
||||||
|
it from all contacts. This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDeleteDialogOpen(false)}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="bg-red-600 hover:bg-red-700 text-white"
|
||||||
|
>
|
||||||
|
{deleting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Delete Tag'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
362
src/components/settings/template-manager.tsx
Normal file
362
src/components/settings/template-manager.tsx
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { MessageTemplate } from '@/types';
|
||||||
|
|
||||||
|
const CATEGORIES = ['Marketing', 'Utility', 'Authentication'] as const;
|
||||||
|
const HEADER_TYPES = ['text', 'image', 'video', 'document'] as const;
|
||||||
|
|
||||||
|
const categoryColors: Record<string, string> = {
|
||||||
|
Marketing: 'bg-purple-600/20 text-purple-400 border-purple-600/30',
|
||||||
|
Utility: 'bg-blue-600/20 text-blue-400 border-blue-600/30',
|
||||||
|
Authentication: 'bg-amber-600/20 text-amber-400 border-amber-600/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
Draft: 'bg-slate-600/20 text-slate-400 border-slate-600/30',
|
||||||
|
Pending: 'bg-yellow-600/20 text-yellow-400 border-yellow-600/30',
|
||||||
|
Approved: 'bg-emerald-600/20 text-emerald-400 border-emerald-600/30',
|
||||||
|
Rejected: 'bg-red-600/20 text-red-400 border-red-600/30',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TemplateFormData {
|
||||||
|
name: string;
|
||||||
|
category: MessageTemplate['category'];
|
||||||
|
language: string;
|
||||||
|
body_text: string;
|
||||||
|
header_type: string;
|
||||||
|
footer_text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyForm: TemplateFormData = {
|
||||||
|
name: '',
|
||||||
|
category: 'Marketing',
|
||||||
|
language: 'en',
|
||||||
|
body_text: '',
|
||||||
|
header_type: '',
|
||||||
|
footer_text: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TemplateManager() {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [templates, setTemplates] = useState<MessageTemplate[]>([]);
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [form, setForm] = useState<TemplateFormData>(emptyForm);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTemplates();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchTemplates() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('message_templates')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
setTemplates(data || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch templates:', err);
|
||||||
|
toast.error('Failed to load templates');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast.error('Template name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.body_text.trim()) {
|
||||||
|
toast.error('Body text is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
toast.error('Not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
user_id: user.id,
|
||||||
|
name: form.name.trim(),
|
||||||
|
category: form.category,
|
||||||
|
language: form.language.trim() || 'en',
|
||||||
|
body_text: form.body_text.trim(),
|
||||||
|
header_type: form.header_type || null,
|
||||||
|
footer_text: form.footer_text.trim() || null,
|
||||||
|
status: 'Draft' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('message_templates')
|
||||||
|
.insert(payload);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
toast.success('Template created successfully');
|
||||||
|
setDialogOpen(false);
|
||||||
|
setForm(emptyForm);
|
||||||
|
await fetchTemplates();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save error:', err);
|
||||||
|
toast.error('Failed to create template');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: string) {
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('message_templates')
|
||||||
|
.delete()
|
||||||
|
.eq('id', id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
toast.success('Template deleted');
|
||||||
|
setTemplates((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Delete error:', err);
|
||||||
|
toast.error('Failed to delete template');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="size-6 animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Message Templates</h2>
|
||||||
|
<p className="text-sm text-slate-400">Create and manage your WhatsApp message templates.</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setForm(emptyForm);
|
||||||
|
setDialogOpen(true);
|
||||||
|
}}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
New Template
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templates.length === 0 ? (
|
||||||
|
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<p className="text-slate-400 text-sm">No templates yet.</p>
|
||||||
|
<p className="text-slate-500 text-xs mt-1">Create your first message template to get started.</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{templates.map((template) => (
|
||||||
|
<Card key={template.id} className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||||
|
<CardContent className="flex items-start justify-between pt-4">
|
||||||
|
<div className="space-y-2 min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="font-medium text-white">{template.name}</h3>
|
||||||
|
<Badge
|
||||||
|
className={`text-xs border ${categoryColors[template.category] || ''}`}
|
||||||
|
>
|
||||||
|
{template.category}
|
||||||
|
</Badge>
|
||||||
|
<Badge
|
||||||
|
className={`text-xs border ${statusColors[template.status || 'Draft'] || ''}`}
|
||||||
|
>
|
||||||
|
{template.status || 'Draft'}
|
||||||
|
</Badge>
|
||||||
|
{template.language && (
|
||||||
|
<span className="text-xs text-slate-500 uppercase">{template.language}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-400 line-clamp-2">{template.body_text}</p>
|
||||||
|
{template.footer_text && (
|
||||||
|
<p className="text-xs text-slate-500 italic">{template.footer_text}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleDelete(template.id)}
|
||||||
|
className="text-slate-400 hover:text-red-400 hover:bg-red-950/30 shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* New Template Dialog */}
|
||||||
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-700 sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-white">New Message Template</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-400">
|
||||||
|
Create a new WhatsApp message template.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Template Name</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. order_confirmation"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Category</Label>
|
||||||
|
<Select
|
||||||
|
value={form.category}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setForm({ ...form, category: val as MessageTemplate['category'] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-slate-800 border-slate-700">
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<SelectItem key={cat} value={cat} className="text-white focus:bg-slate-700 focus:text-white">
|
||||||
|
{cat}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Language</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="en"
|
||||||
|
value={form.language}
|
||||||
|
onChange={(e) => setForm({ ...form, language: e.target.value })}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Header Type</Label>
|
||||||
|
<Select
|
||||||
|
value={form.header_type}
|
||||||
|
onValueChange={(val) => setForm({ ...form, header_type: val || '' })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-full bg-slate-800 border-slate-700 text-white">
|
||||||
|
<SelectValue placeholder="None" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-slate-800 border-slate-700">
|
||||||
|
<SelectItem value="none" className="text-white focus:bg-slate-700 focus:text-white">
|
||||||
|
None
|
||||||
|
</SelectItem>
|
||||||
|
{HEADER_TYPES.map((type) => (
|
||||||
|
<SelectItem key={type} value={type} className="text-white focus:bg-slate-700 focus:text-white">
|
||||||
|
{type.charAt(0).toUpperCase() + type.slice(1)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Body Text</Label>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Enter your template message body. Use {{1}}, {{2}} for variables."
|
||||||
|
value={form.body_text}
|
||||||
|
onChange={(e) => setForm({ ...form, body_text: e.target.value })}
|
||||||
|
rows={4}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Footer Text</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Optional footer text"
|
||||||
|
value={form.footer_text}
|
||||||
|
onChange={(e) => setForm({ ...form, footer_text: e.target.value })}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="bg-slate-900 border-slate-700">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDialogOpen(false)}
|
||||||
|
className="border-slate-700 text-slate-300 hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Creating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Create Template'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
447
src/components/settings/whatsapp-config.tsx
Normal file
447
src/components/settings/whatsapp-config.tsx
Normal file
@ -0,0 +1,447 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Copy,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
ExternalLink,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
AccordionContent,
|
||||||
|
} from '@/components/ui/accordion';
|
||||||
|
import type { WhatsAppConfig as WhatsAppConfigType } from '@/types';
|
||||||
|
|
||||||
|
const MASKED_TOKEN = '••••••••••••••••';
|
||||||
|
|
||||||
|
export function WhatsAppConfig() {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [showToken, setShowToken] = useState(false);
|
||||||
|
const [config, setConfig] = useState<WhatsAppConfigType | null>(null);
|
||||||
|
const [connectionStatus, setConnectionStatus] = useState<'connected' | 'disconnected' | 'unknown'>('unknown');
|
||||||
|
|
||||||
|
const [phoneNumberId, setPhoneNumberId] = useState('');
|
||||||
|
const [wabaId, setWabaId] = useState('');
|
||||||
|
const [accessToken, setAccessToken] = useState('');
|
||||||
|
const [verifyToken, setVerifyToken] = useState('');
|
||||||
|
const [tokenEdited, setTokenEdited] = useState(false);
|
||||||
|
|
||||||
|
const webhookUrl =
|
||||||
|
typeof window !== 'undefined'
|
||||||
|
? `${window.location.origin}/api/whatsapp/webhook`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function fetchConfig() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.select('*')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error && error.code !== 'PGRST116') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setConfig(data);
|
||||||
|
setPhoneNumberId(data.phone_number_id || '');
|
||||||
|
setWabaId(data.waba_id || '');
|
||||||
|
setAccessToken(MASKED_TOKEN);
|
||||||
|
setVerifyToken(data.verify_token || '');
|
||||||
|
setConnectionStatus(data.status || 'disconnected');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch config:', err);
|
||||||
|
toast.error('Failed to load WhatsApp configuration');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!phoneNumberId.trim()) {
|
||||||
|
toast.error('Phone Number ID is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!tokenEdited && !config) {
|
||||||
|
toast.error('Access Token is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
toast.error('Not authenticated');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
user_id: user.id,
|
||||||
|
phone_number_id: phoneNumberId.trim(),
|
||||||
|
waba_id: wabaId.trim() || null,
|
||||||
|
verify_token: verifyToken.trim() || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (tokenEdited && accessToken !== MASKED_TOKEN) {
|
||||||
|
payload.access_token = accessToken.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?.id) {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.update(payload)
|
||||||
|
.eq('id', config.id);
|
||||||
|
if (error) throw error;
|
||||||
|
} else {
|
||||||
|
if (!tokenEdited || !accessToken.trim() || accessToken === MASKED_TOKEN) {
|
||||||
|
toast.error('Access Token is required for initial setup');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
payload.access_token = accessToken.trim();
|
||||||
|
payload.status = 'disconnected';
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.insert(payload);
|
||||||
|
if (error) throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success('Configuration saved successfully');
|
||||||
|
await fetchConfig();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Save error:', err);
|
||||||
|
toast.error('Failed to save configuration');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleTestConnection() {
|
||||||
|
try {
|
||||||
|
setTesting(true);
|
||||||
|
const response = await fetch('/api/whatsapp/config', {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setConnectionStatus('connected');
|
||||||
|
toast.success('API connection successful');
|
||||||
|
|
||||||
|
if (config?.id) {
|
||||||
|
await supabase
|
||||||
|
.from('whatsapp_config')
|
||||||
|
.update({ status: 'connected', connected_at: new Date().toISOString() })
|
||||||
|
.eq('id', config.id);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
toast.error('API connection failed. Check your credentials.');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setConnectionStatus('disconnected');
|
||||||
|
toast.error('Connection test failed. Ensure your server is running.');
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopyWebhookUrl() {
|
||||||
|
navigator.clipboard.writeText(webhookUrl);
|
||||||
|
toast.success('Webhook URL copied to clipboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="size-6 animate-spin text-emerald-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[1fr_380px] mt-4">
|
||||||
|
{/* Main config form */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Connection Status */}
|
||||||
|
<Alert className="bg-slate-900 border-slate-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{connectionStatus === 'connected' ? (
|
||||||
|
<CheckCircle2 className="size-4 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="size-4 text-red-500" />
|
||||||
|
)}
|
||||||
|
<AlertTitle className="text-white mb-0">
|
||||||
|
{connectionStatus === 'connected' ? 'Connected' : 'Not Connected'}
|
||||||
|
</AlertTitle>
|
||||||
|
</div>
|
||||||
|
<AlertDescription className="text-slate-400">
|
||||||
|
{connectionStatus === 'connected'
|
||||||
|
? 'Your WhatsApp Business API is connected and ready to send/receive messages.'
|
||||||
|
: 'Configure your Meta API credentials below to connect your WhatsApp Business account.'}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* API Credentials */}
|
||||||
|
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">API Credentials</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
Enter your Meta WhatsApp Business API credentials.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Phone Number ID</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 100234567890123"
|
||||||
|
value={phoneNumberId}
|
||||||
|
onChange={(e) => setPhoneNumberId(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">WhatsApp Business Account ID</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="e.g. 100234567890456"
|
||||||
|
value={wabaId}
|
||||||
|
onChange={(e) => setWabaId(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Permanent Access Token</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
type={showToken ? 'text' : 'password'}
|
||||||
|
placeholder="Enter your access token"
|
||||||
|
value={accessToken}
|
||||||
|
onChange={(e) => {
|
||||||
|
setAccessToken(e.target.value);
|
||||||
|
setTokenEdited(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => {
|
||||||
|
if (accessToken === MASKED_TOKEN) {
|
||||||
|
setAccessToken('');
|
||||||
|
setTokenEdited(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 pr-10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowToken(!showToken)}
|
||||||
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
{showToken ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Webhook Verify Token</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="Create a custom verify token"
|
||||||
|
value={verifyToken}
|
||||||
|
onChange={(e) => setVerifyToken(e.target.value)}
|
||||||
|
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
A custom string you create. Must match the token you set in Meta webhook settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Webhook URL */}
|
||||||
|
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white">Webhook Configuration</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
Use this URL as your webhook callback in the Meta App Dashboard.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">Webhook Callback URL</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
readOnly
|
||||||
|
value={webhookUrl}
|
||||||
|
className="bg-slate-800 border-slate-700 text-slate-300 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCopyWebhookUrl}
|
||||||
|
className="shrink-0 border-slate-700 text-slate-300 hover:text-white hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
<Copy className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-emerald-600 hover:bg-emerald-700 text-white"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Save Configuration'
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTestConnection}
|
||||||
|
disabled={testing || !config}
|
||||||
|
className="border-slate-700 text-slate-300 hover:text-white hover:bg-slate-800"
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Testing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Zap className="size-4" />
|
||||||
|
Test API Connection
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Setup Instructions Sidebar */}
|
||||||
|
<div>
|
||||||
|
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-white text-base">Setup Instructions</CardTitle>
|
||||||
|
<CardDescription className="text-slate-400">
|
||||||
|
Follow these steps to connect your WhatsApp Business API.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Accordion>
|
||||||
|
<AccordionItem className="border-slate-700">
|
||||||
|
<AccordionTrigger className="text-slate-300 hover:text-white hover:no-underline">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="flex size-5 items-center justify-center rounded-full bg-emerald-600 text-xs font-bold text-white">1</span>
|
||||||
|
Create a Meta App
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-slate-400">
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||||
|
<li>Go to <span className="text-emerald-400">developers.facebook.com</span></li>
|
||||||
|
<li>Click "My Apps" and then "Create App"</li>
|
||||||
|
<li>Select "Business" as the app type</li>
|
||||||
|
<li>Fill in app details and create</li>
|
||||||
|
</ol>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem className="border-slate-700">
|
||||||
|
<AccordionTrigger className="text-slate-300 hover:text-white hover:no-underline">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="flex size-5 items-center justify-center rounded-full bg-emerald-600 text-xs font-bold text-white">2</span>
|
||||||
|
Add WhatsApp Product
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-slate-400">
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||||
|
<li>In your app dashboard, click "Add Product"</li>
|
||||||
|
<li>Find "WhatsApp" and click "Set Up"</li>
|
||||||
|
<li>Follow the setup wizard to link your business</li>
|
||||||
|
</ol>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem className="border-slate-700">
|
||||||
|
<AccordionTrigger className="text-slate-300 hover:text-white hover:no-underline">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="flex size-5 items-center justify-center rounded-full bg-emerald-600 text-xs font-bold text-white">3</span>
|
||||||
|
Get API Credentials
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-slate-400">
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||||
|
<li>Go to WhatsApp > API Setup</li>
|
||||||
|
<li>Copy your <strong className="text-slate-200">Phone Number ID</strong></li>
|
||||||
|
<li>Copy your <strong className="text-slate-200">WhatsApp Business Account ID</strong></li>
|
||||||
|
<li>Generate a <strong className="text-slate-200">Permanent Access Token</strong> from Business Settings > System Users</li>
|
||||||
|
</ol>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem className="border-slate-700">
|
||||||
|
<AccordionTrigger className="text-slate-300 hover:text-white hover:no-underline">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="flex size-5 items-center justify-center rounded-full bg-emerald-600 text-xs font-bold text-white">4</span>
|
||||||
|
Configure Webhooks
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="text-slate-400">
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-sm">
|
||||||
|
<li>Go to WhatsApp > Configuration</li>
|
||||||
|
<li>Click "Edit" on the Webhook section</li>
|
||||||
|
<li>Paste the <strong className="text-slate-200">Webhook Callback URL</strong> from above</li>
|
||||||
|
<li>Enter the same <strong className="text-slate-200">Verify Token</strong> you set here</li>
|
||||||
|
<li>Subscribe to "messages" webhook field</li>
|
||||||
|
</ol>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<div className="mt-4 pt-4 border-t border-slate-700">
|
||||||
|
<a
|
||||||
|
href="https://developers.facebook.com/docs/whatsapp/cloud-api/get-started"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-emerald-400 hover:text-emerald-300 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="size-3.5" />
|
||||||
|
Meta WhatsApp API Documentation
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/ui/accordion.tsx
Normal file
72
src/components/ui/accordion.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { Accordion as AccordionPrimitive } from "@base-ui/react/accordion"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Root
|
||||||
|
data-slot="accordion"
|
||||||
|
className={cn("flex w-full flex-col", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Item
|
||||||
|
data-slot="accordion-item"
|
||||||
|
className={cn("not-last:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionTrigger({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AccordionPrimitive.Trigger.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Header className="flex">
|
||||||
|
<AccordionPrimitive.Trigger
|
||||||
|
data-slot="accordion-trigger"
|
||||||
|
className={cn(
|
||||||
|
"group/accordion-trigger relative flex flex-1 items-start justify-between rounded-lg border border-transparent py-2.5 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:after:border-ring aria-disabled:pointer-events-none aria-disabled:opacity-50 **:data-[slot=accordion-trigger-icon]:ml-auto **:data-[slot=accordion-trigger-icon]:size-4 **:data-[slot=accordion-trigger-icon]:text-muted-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronDownIcon data-slot="accordion-trigger-icon" className="pointer-events-none shrink-0 group-aria-expanded/accordion-trigger:hidden" />
|
||||||
|
<ChevronUpIcon data-slot="accordion-trigger-icon" className="pointer-events-none hidden shrink-0 group-aria-expanded/accordion-trigger:inline" />
|
||||||
|
</AccordionPrimitive.Trigger>
|
||||||
|
</AccordionPrimitive.Header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AccordionContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: AccordionPrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<AccordionPrimitive.Panel
|
||||||
|
data-slot="accordion-content"
|
||||||
|
className="overflow-hidden text-sm data-open:animate-accordion-down data-closed:animate-accordion-up"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-(--accordion-panel-height) pt-0 pb-2.5 data-ending-style:h-0 data-starting-style:h-0 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</AccordionPrimitive.Panel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
|
||||||
76
src/components/ui/alert.tsx
Normal file
76
src/components/ui/alert.tsx
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
"group/alert relative grid w-full gap-0.5 rounded-lg border px-2.5 py-2 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading font-medium group-has-[>svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-balance text-muted-foreground md:text-pretty [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground [&_p:not(:last-child)]:mb-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn("absolute top-2 right-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
109
src/components/ui/avatar.tsx
Normal file
109
src/components/ui/avatar.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Avatar({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: AvatarPrimitive.Root.Props & {
|
||||||
|
size?: "default" | "sm" | "lg"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
data-slot="avatar-image"
|
||||||
|
className={cn(
|
||||||
|
"aspect-square size-full rounded-full object-cover",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarFallback({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: AvatarPrimitive.Fallback.Props) {
|
||||||
|
return (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
data-slot="avatar-fallback"
|
||||||
|
className={cn(
|
||||||
|
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group"
|
||||||
|
className={cn(
|
||||||
|
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AvatarGroupCount({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
className={cn(
|
||||||
|
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Avatar,
|
||||||
|
AvatarImage,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarGroup,
|
||||||
|
AvatarGroupCount,
|
||||||
|
AvatarBadge,
|
||||||
|
}
|
||||||
52
src/components/ui/badge.tsx
Normal file
52
src/components/ui/badge.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { mergeProps } from "@base-ui/react/merge-props"
|
||||||
|
import { useRender } from "@base-ui/react/use-render"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const badgeVariants = cva(
|
||||||
|
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||||
|
outline:
|
||||||
|
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
render,
|
||||||
|
...props
|
||||||
|
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||||
|
return useRender({
|
||||||
|
defaultTagName: "span",
|
||||||
|
props: mergeProps<"span">(
|
||||||
|
{
|
||||||
|
className: cn(badgeVariants({ variant }), className),
|
||||||
|
},
|
||||||
|
props
|
||||||
|
),
|
||||||
|
render,
|
||||||
|
state: {
|
||||||
|
slot: "badge",
|
||||||
|
variant,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
103
src/components/ui/card.tsx
Normal file
103
src/components/ui/card.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Card({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
}
|
||||||
160
src/components/ui/dialog.tsx
Normal file
160
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||||
|
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||||
|
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogOverlay({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Backdrop
|
||||||
|
data-slot="dialog-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Popup.Props & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Popup
|
||||||
|
data-slot="dialog-content"
|
||||||
|
className={cn(
|
||||||
|
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
data-slot="dialog-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-2 right-2"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-header"
|
||||||
|
className={cn("flex flex-col gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogFooter({
|
||||||
|
className,
|
||||||
|
showCloseButton = false,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="dialog-footer"
|
||||||
|
className={cn(
|
||||||
|
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||||
|
Close
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
data-slot="dialog-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base leading-none font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: DialogPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Description
|
||||||
|
data-slot="dialog-description"
|
||||||
|
className={cn(
|
||||||
|
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
}
|
||||||
268
src/components/ui/dropdown-menu.tsx
Normal file
268
src/components/ui/dropdown-menu.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||||
|
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||||
|
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||||
|
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
MenuPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Portal>
|
||||||
|
<MenuPrimitive.Positioner
|
||||||
|
className="isolate z-50 outline-none"
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
>
|
||||||
|
<MenuPrimitive.Popup
|
||||||
|
data-slot="dropdown-menu-content"
|
||||||
|
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.Positioner>
|
||||||
|
</MenuPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||||
|
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuLabel({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.GroupLabel.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.GroupLabel
|
||||||
|
data-slot="dropdown-menu-label"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuItem({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Item.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
variant?: "default" | "destructive"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Item
|
||||||
|
data-slot="dropdown-menu-item"
|
||||||
|
data-inset={inset}
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(
|
||||||
|
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||||
|
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubTrigger({
|
||||||
|
className,
|
||||||
|
inset,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.SubmenuTrigger
|
||||||
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto" />
|
||||||
|
</MenuPrimitive.SubmenuTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSubContent({
|
||||||
|
align = "start",
|
||||||
|
alignOffset = -3,
|
||||||
|
side = "right",
|
||||||
|
sideOffset = 0,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||||
|
return (
|
||||||
|
<DropdownMenuContent
|
||||||
|
data-slot="dropdown-menu-sub-content"
|
||||||
|
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuCheckboxItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
checked,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.CheckboxItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.CheckboxItem
|
||||||
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.CheckboxItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.CheckboxItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioGroup
|
||||||
|
data-slot="dropdown-menu-radio-group"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuRadioItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
inset,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.RadioItem.Props & {
|
||||||
|
inset?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.RadioItem
|
||||||
|
data-slot="dropdown-menu-radio-item"
|
||||||
|
data-inset={inset}
|
||||||
|
className={cn(
|
||||||
|
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||||
|
data-slot="dropdown-menu-radio-item-indicator"
|
||||||
|
>
|
||||||
|
<MenuPrimitive.RadioItemIndicator>
|
||||||
|
<CheckIcon
|
||||||
|
/>
|
||||||
|
</MenuPrimitive.RadioItemIndicator>
|
||||||
|
</span>
|
||||||
|
{children}
|
||||||
|
</MenuPrimitive.RadioItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: MenuPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<MenuPrimitive.Separator
|
||||||
|
data-slot="dropdown-menu-separator"
|
||||||
|
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownMenuShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="dropdown-menu-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuPortal,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuRadioGroup,
|
||||||
|
DropdownMenuRadioItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuSub,
|
||||||
|
DropdownMenuSubTrigger,
|
||||||
|
DropdownMenuSubContent,
|
||||||
|
}
|
||||||
20
src/components/ui/input.tsx
Normal file
20
src/components/ui/input.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<InputPrimitive
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input }
|
||||||
20
src/components/ui/label.tsx
Normal file
20
src/components/ui/label.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label }
|
||||||
90
src/components/ui/popover.tsx
Normal file
90
src/components/ui/popover.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
PopoverPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<PopoverPrimitive.Popup
|
||||||
|
data-slot="popover-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Positioner>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Title
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-heading font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PopoverPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Description
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverDescription,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverTrigger,
|
||||||
|
}
|
||||||
38
src/components/ui/radio-group.tsx
Normal file
38
src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Radio as RadioPrimitive } from "@base-ui/react/radio"
|
||||||
|
import { RadioGroup as RadioGroupPrimitive } from "@base-ui/react/radio-group"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function RadioGroup({ className, ...props }: RadioGroupPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid w-full gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({ className, ...props }: RadioPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<RadioPrimitive.Root
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"group/radio-group-item peer relative flex aspect-square size-4 shrink-0 rounded-full border border-input outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="flex size-4 items-center justify-center"
|
||||||
|
>
|
||||||
|
<span className="absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary-foreground" />
|
||||||
|
</RadioPrimitive.Indicator>
|
||||||
|
</RadioPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem }
|
||||||
55
src/components/ui/scroll-area.tsx
Normal file
55
src/components/ui/scroll-area.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function ScrollArea({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ScrollAreaPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Root
|
||||||
|
data-slot="scroll-area"
|
||||||
|
className={cn("relative", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Viewport
|
||||||
|
data-slot="scroll-area-viewport"
|
||||||
|
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollAreaPrimitive.Viewport>
|
||||||
|
<ScrollBar />
|
||||||
|
<ScrollAreaPrimitive.Corner />
|
||||||
|
</ScrollAreaPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrollBar({
|
||||||
|
className,
|
||||||
|
orientation = "vertical",
|
||||||
|
...props
|
||||||
|
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||||
|
return (
|
||||||
|
<ScrollAreaPrimitive.Scrollbar
|
||||||
|
data-slot="scroll-area-scrollbar"
|
||||||
|
data-orientation={orientation}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ScrollAreaPrimitive.Thumb
|
||||||
|
data-slot="scroll-area-thumb"
|
||||||
|
className="relative flex-1 rounded-full bg-border"
|
||||||
|
/>
|
||||||
|
</ScrollAreaPrimitive.Scrollbar>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ScrollArea, ScrollBar }
|
||||||
201
src/components/ui/select.tsx
Normal file
201
src/components/ui/select.tsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Group
|
||||||
|
data-slot="select-group"
|
||||||
|
className={cn("scroll-my-1 p-1", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Value
|
||||||
|
data-slot="select-value"
|
||||||
|
className={cn("flex flex-1 text-left", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Trigger.Props & {
|
||||||
|
size?: "sm" | "default"
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon
|
||||||
|
render={
|
||||||
|
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "bottom",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
alignItemWithTrigger = true,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
SelectPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Positioner
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
alignItemWithTrigger={alignItemWithTrigger}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<SelectPrimitive.Popup
|
||||||
|
data-slot="select-content"
|
||||||
|
data-align-trigger={alignItemWithTrigger}
|
||||||
|
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Popup>
|
||||||
|
</SelectPrimitive.Positioner>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.GroupLabel.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.GroupLabel
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Item.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.ItemText>
|
||||||
|
<SelectPrimitive.ItemIndicator
|
||||||
|
render={
|
||||||
|
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CheckIcon className="pointer-events-none" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SelectPrimitive.Separator.Props) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpArrow
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollUpArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownArrow
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon
|
||||||
|
/>
|
||||||
|
</SelectPrimitive.ScrollDownArrow>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
}
|
||||||
25
src/components/ui/separator.tsx
Normal file
25
src/components/ui/separator.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: SeparatorPrimitive.Props) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive
|
||||||
|
data-slot="separator"
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator }
|
||||||
138
src/components/ui/sheet.tsx
Normal file
138
src/components/ui/sheet.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { XIcon } from "lucide-react"
|
||||||
|
|
||||||
|
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||||
|
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||||
|
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||||
|
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||||
|
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Backdrop
|
||||||
|
data-slot="sheet-overlay"
|
||||||
|
className={cn(
|
||||||
|
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
side = "right",
|
||||||
|
showCloseButton = true,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Popup.Props & {
|
||||||
|
side?: "top" | "right" | "bottom" | "left"
|
||||||
|
showCloseButton?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Popup
|
||||||
|
data-slot="sheet-content"
|
||||||
|
data-side={side}
|
||||||
|
className={cn(
|
||||||
|
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{showCloseButton && (
|
||||||
|
<SheetPrimitive.Close
|
||||||
|
data-slot="sheet-close"
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="absolute top-3 right-3"
|
||||||
|
size="icon-sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<XIcon
|
||||||
|
/>
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</SheetPrimitive.Popup>
|
||||||
|
</SheetPortal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-header"
|
||||||
|
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="sheet-footer"
|
||||||
|
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
data-slot="sheet-title"
|
||||||
|
className={cn(
|
||||||
|
"font-heading text-base font-medium text-foreground",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SheetDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: SheetPrimitive.Description.Props) {
|
||||||
|
return (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
data-slot="sheet-description"
|
||||||
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription,
|
||||||
|
}
|
||||||
116
src/components/ui/table.tsx
Normal file
116
src/components/ui/table.tsx
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="table-container"
|
||||||
|
className="relative w-full overflow-x-auto"
|
||||||
|
>
|
||||||
|
<table
|
||||||
|
data-slot="table"
|
||||||
|
className={cn("w-full caption-bottom text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||||
|
return (
|
||||||
|
<thead
|
||||||
|
data-slot="table-header"
|
||||||
|
className={cn("[&_tr]:border-b", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||||
|
return (
|
||||||
|
<tbody
|
||||||
|
data-slot="table-body"
|
||||||
|
className={cn("[&_tr:last-child]:border-0", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||||
|
return (
|
||||||
|
<tfoot
|
||||||
|
data-slot="table-footer"
|
||||||
|
className={cn(
|
||||||
|
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
data-slot="table-row"
|
||||||
|
className={cn(
|
||||||
|
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
data-slot="table-head"
|
||||||
|
className={cn(
|
||||||
|
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
data-slot="table-cell"
|
||||||
|
className={cn(
|
||||||
|
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableCaption({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"caption">) {
|
||||||
|
return (
|
||||||
|
<caption
|
||||||
|
data-slot="table-caption"
|
||||||
|
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableFooter,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
TableCaption,
|
||||||
|
}
|
||||||
82
src/components/ui/tabs.tsx
Normal file
82
src/components/ui/tabs.tsx
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Tabs({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.Root.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Root
|
||||||
|
data-slot="tabs"
|
||||||
|
data-orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsListVariants = cva(
|
||||||
|
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-muted",
|
||||||
|
line: "gap-1 bg-transparent",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function TabsList({
|
||||||
|
className,
|
||||||
|
variant = "default",
|
||||||
|
...props
|
||||||
|
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
data-slot="tabs-list"
|
||||||
|
data-variant={variant}
|
||||||
|
className={cn(tabsListVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Tab
|
||||||
|
data-slot="tabs-trigger"
|
||||||
|
className={cn(
|
||||||
|
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||||
|
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||||
|
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||||
|
return (
|
||||||
|
<TabsPrimitive.Panel
|
||||||
|
data-slot="tabs-content"
|
||||||
|
className={cn("flex-1 text-sm outline-none", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
data-slot="textarea"
|
||||||
|
className={cn(
|
||||||
|
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
66
src/components/ui/tooltip.tsx
Normal file
66
src/components/ui/tooltip.tsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function TooltipProvider({
|
||||||
|
delay = 0,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Provider.Props) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Provider
|
||||||
|
data-slot="tooltip-provider"
|
||||||
|
delay={delay}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ ...props }: TooltipPrimitive.Root.Props) {
|
||||||
|
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipTrigger({ ...props }: TooltipPrimitive.Trigger.Props) {
|
||||||
|
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function TooltipContent({
|
||||||
|
className,
|
||||||
|
side = "top",
|
||||||
|
sideOffset = 4,
|
||||||
|
align = "center",
|
||||||
|
alignOffset = 0,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: TooltipPrimitive.Popup.Props &
|
||||||
|
Pick<
|
||||||
|
TooltipPrimitive.Positioner.Props,
|
||||||
|
"align" | "alignOffset" | "side" | "sideOffset"
|
||||||
|
>) {
|
||||||
|
return (
|
||||||
|
<TooltipPrimitive.Portal>
|
||||||
|
<TooltipPrimitive.Positioner
|
||||||
|
align={align}
|
||||||
|
alignOffset={alignOffset}
|
||||||
|
side={side}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className="isolate z-50"
|
||||||
|
>
|
||||||
|
<TooltipPrimitive.Popup
|
||||||
|
data-slot="tooltip-content"
|
||||||
|
className={cn(
|
||||||
|
"z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground data-[side=bottom]:top-1 data-[side=inline-end]:top-1/2! data-[side=inline-end]:-left-1 data-[side=inline-end]:-translate-y-1/2 data-[side=inline-start]:top-1/2! data-[side=inline-start]:-right-1 data-[side=inline-start]:-translate-y-1/2 data-[side=left]:top-1/2! data-[side=left]:-right-1 data-[side=left]:-translate-y-1/2 data-[side=right]:top-1/2! data-[side=right]:-left-1 data-[side=right]:-translate-y-1/2 data-[side=top]:-bottom-2.5" />
|
||||||
|
</TooltipPrimitive.Popup>
|
||||||
|
</TooltipPrimitive.Positioner>
|
||||||
|
</TooltipPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||||
81
src/hooks/use-auth.ts
Normal file
81
src/hooks/use-auth.ts
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useCallback } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import type { User } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
interface Profile {
|
||||||
|
id: string;
|
||||||
|
full_name: string | null;
|
||||||
|
email: string;
|
||||||
|
avatar_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [profile, setProfile] = useState<Profile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const fetchProfile = useCallback(
|
||||||
|
async (userId: string) => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("id, full_name, email, avatar_url")
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setProfile(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[supabase]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getUser = async () => {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
setUser(user);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
await fetchProfile(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
getUser();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange(async (event, session) => {
|
||||||
|
const currentUser = session?.user ?? null;
|
||||||
|
setUser(currentUser);
|
||||||
|
|
||||||
|
if (currentUser) {
|
||||||
|
await fetchProfile(currentUser.id);
|
||||||
|
} else {
|
||||||
|
setProfile(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.unsubscribe();
|
||||||
|
};
|
||||||
|
}, [supabase, fetchProfile]);
|
||||||
|
|
||||||
|
const signOut = useCallback(async () => {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
setUser(null);
|
||||||
|
setProfile(null);
|
||||||
|
window.location.href = "/login";
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
|
return { user, profile, loading, signOut };
|
||||||
|
}
|
||||||
263
src/hooks/use-broadcast-sending.ts
Normal file
263
src/hooks/use-broadcast-sending.ts
Normal file
@ -0,0 +1,263 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { createClient } from '@/lib/supabase/client';
|
||||||
|
import { Contact, MessageTemplate } from '@/types';
|
||||||
|
|
||||||
|
interface AudienceConfig {
|
||||||
|
type: 'all' | 'tags' | 'csv';
|
||||||
|
tagIds?: string[];
|
||||||
|
csvContacts?: { phone: string; name?: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BroadcastPayload {
|
||||||
|
name: string;
|
||||||
|
template: MessageTemplate;
|
||||||
|
audience: AudienceConfig;
|
||||||
|
variables: Record<string, { type: 'static' | 'field'; value: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseBroadcastSendingReturn {
|
||||||
|
createAndSendBroadcast: (payload: BroadcastPayload) => Promise<string>;
|
||||||
|
isProcessing: boolean;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useBroadcastSending(): UseBroadcastSendingReturn {
|
||||||
|
const [isProcessing, setIsProcessing] = useState(false);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
|
||||||
|
async function resolveAudience(audience: AudienceConfig): Promise<Contact[]> {
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
if (audience.type === 'all') {
|
||||||
|
const { data, error } = await supabase.from('contacts').select('*');
|
||||||
|
if (error) throw new Error(`Failed to fetch contacts: ${error.message}`);
|
||||||
|
return data ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audience.type === 'tags' && audience.tagIds && audience.tagIds.length > 0) {
|
||||||
|
const { data: contactTags, error: tagError } = await supabase
|
||||||
|
.from('contact_tags')
|
||||||
|
.select('contact_id')
|
||||||
|
.in('tag_id', audience.tagIds);
|
||||||
|
|
||||||
|
if (tagError) throw new Error(`Failed to fetch contact tags: ${tagError.message}`);
|
||||||
|
if (!contactTags || contactTags.length === 0) return [];
|
||||||
|
|
||||||
|
const uniqueContactIds = [...new Set(contactTags.map((ct) => ct.contact_id))];
|
||||||
|
|
||||||
|
const { data: contacts, error: contactError } = await supabase
|
||||||
|
.from('contacts')
|
||||||
|
.select('*')
|
||||||
|
.in('id', uniqueContactIds);
|
||||||
|
|
||||||
|
if (contactError) throw new Error(`Failed to fetch contacts: ${contactError.message}`);
|
||||||
|
return contacts ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audience.type === 'csv' && audience.csvContacts) {
|
||||||
|
return audience.csvContacts.map((c, i) => ({
|
||||||
|
id: `csv-${i}`,
|
||||||
|
user_id: '',
|
||||||
|
phone: c.phone,
|
||||||
|
name: c.name,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVariables(
|
||||||
|
variables: Record<string, { type: 'static' | 'field'; value: string }>,
|
||||||
|
contact: Contact
|
||||||
|
): string[] {
|
||||||
|
return Object.keys(variables)
|
||||||
|
.sort()
|
||||||
|
.map((key) => {
|
||||||
|
const v = variables[key];
|
||||||
|
if (v.type === 'static') return v.value;
|
||||||
|
const fieldMap: Record<string, string | undefined> = {
|
||||||
|
name: contact.name,
|
||||||
|
phone: contact.phone,
|
||||||
|
email: contact.email,
|
||||||
|
company: contact.company,
|
||||||
|
};
|
||||||
|
return fieldMap[v.value] ?? '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createAndSendBroadcast(payload: BroadcastPayload): Promise<string> {
|
||||||
|
setIsProcessing(true);
|
||||||
|
setProgress(0);
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 1: Resolve audience contacts
|
||||||
|
setProgress(5);
|
||||||
|
const contacts = await resolveAudience(payload.audience);
|
||||||
|
|
||||||
|
if (contacts.length === 0) {
|
||||||
|
throw new Error('No contacts found for this audience.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Create broadcast record
|
||||||
|
setProgress(10);
|
||||||
|
const { data: broadcast, error: broadcastError } = await supabase
|
||||||
|
.from('broadcasts')
|
||||||
|
.insert({
|
||||||
|
name: payload.name,
|
||||||
|
template_name: payload.template.name,
|
||||||
|
template_language: payload.template.language ?? 'en_US',
|
||||||
|
template_variables: payload.variables,
|
||||||
|
audience_filter: {
|
||||||
|
type: payload.audience.type,
|
||||||
|
tagIds: payload.audience.tagIds,
|
||||||
|
},
|
||||||
|
status: 'sending',
|
||||||
|
total_recipients: contacts.length,
|
||||||
|
sent_count: 0,
|
||||||
|
delivered_count: 0,
|
||||||
|
read_count: 0,
|
||||||
|
replied_count: 0,
|
||||||
|
failed_count: 0,
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (broadcastError || !broadcast) {
|
||||||
|
throw new Error(`Failed to create broadcast: ${broadcastError?.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Create broadcast recipients in batches
|
||||||
|
setProgress(20);
|
||||||
|
const BATCH_SIZE = 50;
|
||||||
|
const recipientRows = contacts.map((contact) => ({
|
||||||
|
broadcast_id: broadcast.id,
|
||||||
|
contact_id: contact.id,
|
||||||
|
status: 'pending' as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
for (let i = 0; i < recipientRows.length; i += BATCH_SIZE) {
|
||||||
|
const batch = recipientRows.slice(i, i + BATCH_SIZE);
|
||||||
|
const { error: recipientError } = await supabase
|
||||||
|
.from('broadcast_recipients')
|
||||||
|
.insert(batch);
|
||||||
|
|
||||||
|
if (recipientError) {
|
||||||
|
console.error('Failed to insert recipient batch:', recipientError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Fetch recipients and send via API in batches
|
||||||
|
setProgress(30);
|
||||||
|
const { data: recipients, error: recipientsFetchError } = await supabase
|
||||||
|
.from('broadcast_recipients')
|
||||||
|
.select('*, contact:contacts(*)')
|
||||||
|
.eq('broadcast_id', broadcast.id);
|
||||||
|
|
||||||
|
if (recipientsFetchError || !recipients) {
|
||||||
|
throw new Error('Failed to fetch broadcast recipients');
|
||||||
|
}
|
||||||
|
|
||||||
|
let sentCount = 0;
|
||||||
|
let failedCount = 0;
|
||||||
|
const totalRecipients = recipients.length;
|
||||||
|
|
||||||
|
for (let i = 0; i < recipients.length; i += BATCH_SIZE) {
|
||||||
|
const batch = recipients.slice(i, i + BATCH_SIZE);
|
||||||
|
const phoneNumbers = batch
|
||||||
|
.map((r) => r.contact?.phone)
|
||||||
|
.filter(Boolean) as string[];
|
||||||
|
|
||||||
|
if (phoneNumbers.length === 0) continue;
|
||||||
|
|
||||||
|
// Resolve template params from the first contact in batch for static values
|
||||||
|
const templateParams = batch.map((r) => {
|
||||||
|
if (r.contact) {
|
||||||
|
return resolveVariables(payload.variables, r.contact);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/whatsapp/broadcast', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
phone_numbers: phoneNumbers,
|
||||||
|
template_name: payload.template.name,
|
||||||
|
template_language: payload.template.language ?? 'en_US',
|
||||||
|
template_params: templateParams[0] ?? [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.error || 'Broadcast API request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update individual recipient statuses based on results
|
||||||
|
if (data.results) {
|
||||||
|
for (let j = 0; j < data.results.length; j++) {
|
||||||
|
const result = data.results[j];
|
||||||
|
const recipient = batch[j];
|
||||||
|
if (!recipient) continue;
|
||||||
|
|
||||||
|
const newStatus = result.status === 'sent' ? 'sent' : 'failed';
|
||||||
|
if (newStatus === 'sent') sentCount++;
|
||||||
|
else failedCount++;
|
||||||
|
|
||||||
|
await supabase
|
||||||
|
.from('broadcast_recipients')
|
||||||
|
.update({
|
||||||
|
status: newStatus,
|
||||||
|
sent_at: newStatus === 'sent' ? new Date().toISOString() : null,
|
||||||
|
error_message: result.error ?? null,
|
||||||
|
})
|
||||||
|
.eq('id', recipient.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Mark entire batch as failed
|
||||||
|
for (const recipient of batch) {
|
||||||
|
failedCount++;
|
||||||
|
await supabase
|
||||||
|
.from('broadcast_recipients')
|
||||||
|
.update({
|
||||||
|
status: 'failed',
|
||||||
|
error_message: err instanceof Error ? err.message : 'Unknown error',
|
||||||
|
})
|
||||||
|
.eq('id', recipient.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressPct = 30 + Math.round(((i + batch.length) / totalRecipients) * 60);
|
||||||
|
setProgress(progressPct);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Update broadcast with final counts
|
||||||
|
setProgress(95);
|
||||||
|
const finalStatus = failedCount === totalRecipients ? 'failed' : 'sent';
|
||||||
|
await supabase
|
||||||
|
.from('broadcasts')
|
||||||
|
.update({
|
||||||
|
status: finalStatus,
|
||||||
|
sent_count: sentCount,
|
||||||
|
failed_count: failedCount,
|
||||||
|
})
|
||||||
|
.eq('id', broadcast.id);
|
||||||
|
|
||||||
|
setProgress(100);
|
||||||
|
return broadcast.id;
|
||||||
|
} finally {
|
||||||
|
setIsProcessing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { createAndSendBroadcast, isProcessing, progress };
|
||||||
|
}
|
||||||
89
src/hooks/use-realtime.ts
Normal file
89
src/hooks/use-realtime.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useCallback, useState } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import type { Message, Conversation } from "@/types";
|
||||||
|
import type { RealtimeChannel } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
interface RealtimeEvent<T> {
|
||||||
|
eventType: "INSERT" | "UPDATE" | "DELETE";
|
||||||
|
new: T;
|
||||||
|
old: Partial<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseRealtimeOptions {
|
||||||
|
channelName: string;
|
||||||
|
onMessageEvent?: (event: RealtimeEvent<Message>) => void;
|
||||||
|
onConversationEvent?: (event: RealtimeEvent<Conversation>) => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRealtime({
|
||||||
|
channelName,
|
||||||
|
onMessageEvent,
|
||||||
|
onConversationEvent,
|
||||||
|
enabled = true,
|
||||||
|
}: UseRealtimeOptions) {
|
||||||
|
const channelRef = useRef<RealtimeChannel | null>(null);
|
||||||
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
// Store latest callbacks in refs to avoid re-subscribing
|
||||||
|
const onMessageRef = useRef(onMessageEvent);
|
||||||
|
onMessageRef.current = onMessageEvent;
|
||||||
|
|
||||||
|
const onConversationRef = useRef(onConversationEvent);
|
||||||
|
onConversationRef.current = onConversationEvent;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
|
||||||
|
const supabase = createClient();
|
||||||
|
|
||||||
|
const channel = supabase
|
||||||
|
.channel(channelName)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{ event: "*", schema: "public", table: "messages" },
|
||||||
|
(payload) => {
|
||||||
|
onMessageRef.current?.({
|
||||||
|
eventType: payload.eventType as RealtimeEvent<Message>["eventType"],
|
||||||
|
new: payload.new as Message,
|
||||||
|
old: payload.old as Partial<Message>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.on(
|
||||||
|
"postgres_changes",
|
||||||
|
{ event: "*", schema: "public", table: "conversations" },
|
||||||
|
(payload) => {
|
||||||
|
onConversationRef.current?.({
|
||||||
|
eventType: payload.eventType as RealtimeEvent<Conversation>["eventType"],
|
||||||
|
new: payload.new as Conversation,
|
||||||
|
old: payload.old as Partial<Conversation>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.subscribe((status) => {
|
||||||
|
setIsConnected(status === "SUBSCRIBED");
|
||||||
|
});
|
||||||
|
|
||||||
|
channelRef.current = channel;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
supabase.removeChannel(channel);
|
||||||
|
channelRef.current = null;
|
||||||
|
setIsConnected(false);
|
||||||
|
};
|
||||||
|
}, [channelName, enabled]);
|
||||||
|
|
||||||
|
const unsubscribe = useCallback(() => {
|
||||||
|
if (channelRef.current) {
|
||||||
|
const supabase = createClient();
|
||||||
|
supabase.removeChannel(channelRef.current);
|
||||||
|
channelRef.current = null;
|
||||||
|
setIsConnected(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isConnected, unsubscribe };
|
||||||
|
}
|
||||||
8
src/lib/supabase/client.ts
Normal file
8
src/lib/supabase/client.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { createBrowserClient } from '@supabase/ssr'
|
||||||
|
|
||||||
|
export function createClient() {
|
||||||
|
return createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
|
||||||
|
)
|
||||||
|
}
|
||||||
28
src/lib/supabase/server.ts
Normal file
28
src/lib/supabase/server.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
|
||||||
|
export async function createClient() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// The `setAll` method was called from a Server Component.
|
||||||
|
// This can be ignored if you have middleware refreshing sessions.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
29
src/lib/whatsapp/encryption.ts
Normal file
29
src/lib/whatsapp/encryption.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import crypto from 'crypto'
|
||||||
|
|
||||||
|
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY!
|
||||||
|
const IV_LENGTH = 16
|
||||||
|
|
||||||
|
export function encrypt(text: string): string {
|
||||||
|
const iv = crypto.randomBytes(IV_LENGTH)
|
||||||
|
const cipher = crypto.createCipheriv(
|
||||||
|
'aes-256-cbc',
|
||||||
|
Buffer.from(ENCRYPTION_KEY, 'hex'),
|
||||||
|
iv
|
||||||
|
)
|
||||||
|
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||||
|
encrypted += cipher.final('hex')
|
||||||
|
return iv.toString('hex') + ':' + encrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decrypt(encryptedText: string): string {
|
||||||
|
const parts = encryptedText.split(':')
|
||||||
|
const iv = Buffer.from(parts[0], 'hex')
|
||||||
|
const decipher = crypto.createDecipheriv(
|
||||||
|
'aes-256-cbc',
|
||||||
|
Buffer.from(ENCRYPTION_KEY, 'hex'),
|
||||||
|
iv
|
||||||
|
)
|
||||||
|
let decrypted = decipher.update(parts[1], 'hex', 'utf8')
|
||||||
|
decrypted += decipher.final('utf8')
|
||||||
|
return decrypted
|
||||||
|
}
|
||||||
138
src/lib/whatsapp/meta-api.ts
Normal file
138
src/lib/whatsapp/meta-api.ts
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
const META_API_VERSION = 'v21.0'
|
||||||
|
const META_API_BASE = `https://graph.facebook.com/${META_API_VERSION}`
|
||||||
|
|
||||||
|
export interface MetaSendResult {
|
||||||
|
messageId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MetaPhoneInfo {
|
||||||
|
id: string
|
||||||
|
display_phone_number: string
|
||||||
|
verified_name?: string
|
||||||
|
quality_rating?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify phone number ID with Meta Graph API
|
||||||
|
*/
|
||||||
|
export async function verifyPhoneNumber(phoneNumberId: string, accessToken: string): Promise<MetaPhoneInfo> {
|
||||||
|
const url = `${META_API_BASE}/${phoneNumberId}?fields=id,display_phone_number,verified_name,quality_rating`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
throw new Error(data.error?.message || `Meta API error: ${response.status}`)
|
||||||
|
}
|
||||||
|
return response.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a text message via Meta WhatsApp API
|
||||||
|
*/
|
||||||
|
export async function sendTextMessage(
|
||||||
|
phoneNumberId: string,
|
||||||
|
accessToken: string,
|
||||||
|
to: string,
|
||||||
|
text: string
|
||||||
|
): Promise<MetaSendResult> {
|
||||||
|
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
messaging_product: 'whatsapp',
|
||||||
|
recipient_type: 'individual',
|
||||||
|
to,
|
||||||
|
type: 'text',
|
||||||
|
text: { body: text },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
throw new Error(data.error?.message || `Meta API error: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
return { messageId: data.messages[0].id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a template message via Meta WhatsApp API
|
||||||
|
*/
|
||||||
|
export async function sendTemplateMessage(
|
||||||
|
phoneNumberId: string,
|
||||||
|
accessToken: string,
|
||||||
|
to: string,
|
||||||
|
templateName: string,
|
||||||
|
language: string = 'en_US',
|
||||||
|
params?: string[]
|
||||||
|
): Promise<MetaSendResult> {
|
||||||
|
const url = `${META_API_BASE}/${phoneNumberId}/messages`
|
||||||
|
|
||||||
|
const template: Record<string, unknown> = {
|
||||||
|
name: templateName,
|
||||||
|
language: { code: language },
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params && params.length > 0) {
|
||||||
|
template.components = [{
|
||||||
|
type: 'body',
|
||||||
|
parameters: params.map(p => ({ type: 'text', text: String(p) })),
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${accessToken}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
messaging_product: 'whatsapp',
|
||||||
|
recipient_type: 'individual',
|
||||||
|
to,
|
||||||
|
type: 'template',
|
||||||
|
template,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
throw new Error(data.error?.message || `Meta API error: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
return { messageId: data.messages[0].id }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get media download URL from Meta
|
||||||
|
*/
|
||||||
|
export async function getMediaUrl(mediaId: string, accessToken: string): Promise<{ url: string; mimeType: string }> {
|
||||||
|
const response = await fetch(`${META_API_BASE}/${mediaId}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
throw new Error(data.error?.message || `Media fetch failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.url) throw new Error('Media URL not found')
|
||||||
|
return { url: data.url, mimeType: data.mime_type || 'application/octet-stream' }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download media binary from Meta CDN
|
||||||
|
*/
|
||||||
|
export async function downloadMedia(downloadUrl: string, accessToken: string): Promise<{ buffer: Buffer; contentType: string }> {
|
||||||
|
const response = await fetch(downloadUrl, {
|
||||||
|
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Media download failed: ${response.status}`)
|
||||||
|
}
|
||||||
|
const contentType = response.headers.get('content-type') || 'application/octet-stream'
|
||||||
|
const buffer = Buffer.from(await response.arrayBuffer())
|
||||||
|
return { buffer, contentType }
|
||||||
|
}
|
||||||
41
src/lib/whatsapp/phone-utils.ts
Normal file
41
src/lib/whatsapp/phone-utils.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Sanitize phone number for Meta WhatsApp API.
|
||||||
|
* Meta requires digits only — no + prefix, no spaces, no dashes.
|
||||||
|
* e.g. "+370 63949836" → "37063949836"
|
||||||
|
*/
|
||||||
|
export function sanitizePhoneForMeta(phone: string): string {
|
||||||
|
if (!phone) return ''
|
||||||
|
return phone.replace(/\D/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize phone number by removing all non-digit characters.
|
||||||
|
* Used for comparing phone numbers in different formats.
|
||||||
|
*/
|
||||||
|
export function normalizePhone(phone: string): string {
|
||||||
|
if (!phone) return ''
|
||||||
|
return phone.replace(/\D/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compare two phone numbers accounting for trunk prefix differences.
|
||||||
|
* e.g. "370063949836" (with trunk 0) matches "37063949836" (without trunk 0)
|
||||||
|
* by comparing the last 8 digits.
|
||||||
|
*/
|
||||||
|
export function phonesMatch(phone1: string, phone2: string): boolean {
|
||||||
|
const n1 = normalizePhone(phone1)
|
||||||
|
const n2 = normalizePhone(phone2)
|
||||||
|
if (n1 === n2) return true
|
||||||
|
if (n1.length >= 8 && n2.length >= 8) {
|
||||||
|
return n1.slice(-8) === n2.slice(-8)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate phone number is E.164-like format (7-15 digits starting with non-zero).
|
||||||
|
* Accepts with or without + prefix.
|
||||||
|
*/
|
||||||
|
export function isValidE164(phone: string): boolean {
|
||||||
|
return /^\+?[1-9]\d{6,14}$/.test(phone)
|
||||||
|
}
|
||||||
60
src/middleware.ts
Normal file
60
src/middleware.ts
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
let supabaseResponse = NextResponse.next({ request })
|
||||||
|
|
||||||
|
const supabase = createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
|
||||||
|
supabaseResponse = NextResponse.next({ request })
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
supabaseResponse.cookies.set(name, value, options)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const { data: { user } } = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
// Auth pages - redirect to dashboard if already logged in
|
||||||
|
if (user && (
|
||||||
|
request.nextUrl.pathname === '/login' ||
|
||||||
|
request.nextUrl.pathname === '/signup' ||
|
||||||
|
request.nextUrl.pathname === '/forgot-password'
|
||||||
|
)) {
|
||||||
|
const url = request.nextUrl.clone()
|
||||||
|
url.pathname = '/dashboard'
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Protected pages - redirect to login if not authenticated
|
||||||
|
const protectedPaths = ['/dashboard', '/inbox', '/contacts', '/pipelines', '/broadcasts', '/automations', '/settings']
|
||||||
|
if (!user && protectedPaths.some(path => request.nextUrl.pathname.startsWith(path))) {
|
||||||
|
const url = request.nextUrl.clone()
|
||||||
|
url.pathname = '/login'
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
// API routes that need auth (not webhooks)
|
||||||
|
if (!user && request.nextUrl.pathname.startsWith('/api/whatsapp/') &&
|
||||||
|
!request.nextUrl.pathname.includes('/webhook')) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return supabaseResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
189
src/types/index.ts
Normal file
189
src/types/index.ts
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
full_name: string;
|
||||||
|
email: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
role: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Contact {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
phone: string;
|
||||||
|
name?: string;
|
||||||
|
email?: string;
|
||||||
|
company?: string;
|
||||||
|
avatar_url?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactTag {
|
||||||
|
id: string;
|
||||||
|
contact_id: string;
|
||||||
|
tag_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomField {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
field_name: string;
|
||||||
|
field_type: string;
|
||||||
|
field_options?: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactCustomValue {
|
||||||
|
id: string;
|
||||||
|
contact_id: string;
|
||||||
|
custom_field_id: string;
|
||||||
|
value?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContactNote {
|
||||||
|
id: string;
|
||||||
|
contact_id: string;
|
||||||
|
user_id: string;
|
||||||
|
note_text: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConversationStatus = 'open' | 'pending' | 'closed';
|
||||||
|
|
||||||
|
export interface Conversation {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
contact_id: string;
|
||||||
|
status: ConversationStatus;
|
||||||
|
assigned_agent_id?: string;
|
||||||
|
last_message_text?: string;
|
||||||
|
last_message_at?: string;
|
||||||
|
unread_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
contact?: Contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SenderType = 'customer' | 'agent' | 'bot';
|
||||||
|
export type ContentType = 'text' | 'image' | 'document' | 'audio' | 'video' | 'location' | 'template';
|
||||||
|
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'read' | 'failed';
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: string;
|
||||||
|
conversation_id: string;
|
||||||
|
sender_type: SenderType;
|
||||||
|
sender_id?: string;
|
||||||
|
content_type: ContentType;
|
||||||
|
content_text?: string;
|
||||||
|
media_url?: string;
|
||||||
|
template_name?: string;
|
||||||
|
message_id?: string;
|
||||||
|
status: MessageStatus;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhatsAppConfig {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
phone_number_id: string;
|
||||||
|
waba_id?: string;
|
||||||
|
access_token: string;
|
||||||
|
verify_token?: string;
|
||||||
|
status: 'connected' | 'disconnected';
|
||||||
|
connected_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageTemplate {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
category: 'Marketing' | 'Utility' | 'Authentication';
|
||||||
|
language?: string;
|
||||||
|
header_type?: 'text' | 'image' | 'video' | 'document';
|
||||||
|
header_content?: string;
|
||||||
|
body_text: string;
|
||||||
|
footer_text?: string;
|
||||||
|
buttons?: Record<string, unknown>[];
|
||||||
|
status?: 'Draft' | 'Pending' | 'Approved' | 'Rejected';
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pipeline {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PipelineStage {
|
||||||
|
id: string;
|
||||||
|
pipeline_id: string;
|
||||||
|
name: string;
|
||||||
|
position: number;
|
||||||
|
color: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Deal {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
pipeline_id: string;
|
||||||
|
stage_id: string;
|
||||||
|
contact_id: string;
|
||||||
|
conversation_id?: string;
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
currency?: string;
|
||||||
|
notes?: string;
|
||||||
|
expected_close_date?: string;
|
||||||
|
status?: string;
|
||||||
|
created_at: string;
|
||||||
|
contact?: Contact;
|
||||||
|
stage?: PipelineStage;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed';
|
||||||
|
export type RecipientStatus = 'pending' | 'sent' | 'delivered' | 'read' | 'replied' | 'failed';
|
||||||
|
|
||||||
|
export interface Broadcast {
|
||||||
|
id: string;
|
||||||
|
user_id: string;
|
||||||
|
name: string;
|
||||||
|
template_name: string;
|
||||||
|
template_language: string;
|
||||||
|
template_variables?: Record<string, unknown>;
|
||||||
|
audience_filter?: Record<string, unknown>;
|
||||||
|
scheduled_at?: string;
|
||||||
|
status: BroadcastStatus;
|
||||||
|
total_recipients: number;
|
||||||
|
sent_count: number;
|
||||||
|
delivered_count: number;
|
||||||
|
read_count: number;
|
||||||
|
replied_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BroadcastRecipient {
|
||||||
|
id: string;
|
||||||
|
broadcast_id: string;
|
||||||
|
contact_id: string;
|
||||||
|
status: RecipientStatus;
|
||||||
|
sent_at?: string;
|
||||||
|
delivered_at?: string;
|
||||||
|
read_at?: string;
|
||||||
|
replied_at?: string;
|
||||||
|
error_message?: string;
|
||||||
|
created_at: string;
|
||||||
|
contact?: Contact;
|
||||||
|
}
|
||||||
359
supabase/migrations/001_initial_schema.sql
Normal file
359
supabase/migrations/001_initial_schema.sql
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
-- Enable UUID extension
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PROFILES
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE profiles (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
full_name TEXT NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
|
avatar_url TEXT,
|
||||||
|
role TEXT DEFAULT 'user',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can view own profile" ON profiles FOR SELECT USING (auth.uid() = user_id);
|
||||||
|
CREATE POLICY "Users can update own profile" ON profiles FOR UPDATE USING (auth.uid() = user_id);
|
||||||
|
CREATE POLICY "Users can insert own profile" ON profiles FOR INSERT WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- CONTACTS
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE contacts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
phone TEXT NOT NULL,
|
||||||
|
name TEXT,
|
||||||
|
email TEXT,
|
||||||
|
company TEXT,
|
||||||
|
avatar_url TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_contacts_user_id ON contacts(user_id);
|
||||||
|
CREATE INDEX idx_contacts_phone ON contacts(phone);
|
||||||
|
|
||||||
|
ALTER TABLE contacts ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage own contacts" ON contacts FOR ALL USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- TAGS
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE tags ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage own tags" ON tags FOR ALL USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- CONTACT_TAGS (many-to-many)
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE contact_tags (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(contact_id, tag_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_contact_tags_contact ON contact_tags(contact_id);
|
||||||
|
CREATE INDEX idx_contact_tags_tag ON contact_tags(tag_id);
|
||||||
|
|
||||||
|
ALTER TABLE contact_tags ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage contact tags" ON contact_tags FOR ALL
|
||||||
|
USING (EXISTS (SELECT 1 FROM contacts WHERE contacts.id = contact_tags.contact_id AND contacts.user_id = auth.uid()));
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- CUSTOM_FIELDS
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE custom_fields (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
field_name TEXT NOT NULL,
|
||||||
|
field_type TEXT NOT NULL DEFAULT 'text',
|
||||||
|
field_options JSONB,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE custom_fields ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage own custom fields" ON custom_fields FOR ALL USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- CONTACT_CUSTOM_VALUES
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE contact_custom_values (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
custom_field_id UUID NOT NULL REFERENCES custom_fields(id) ON DELETE CASCADE,
|
||||||
|
value TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(contact_id, custom_field_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE contact_custom_values ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage custom values" ON contact_custom_values FOR ALL
|
||||||
|
USING (EXISTS (SELECT 1 FROM contacts WHERE contacts.id = contact_custom_values.contact_id AND contacts.user_id = auth.uid()));
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- CONTACT_NOTES
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE contact_notes (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
note_text TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE contact_notes ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage own notes" ON contact_notes FOR ALL USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- CONVERSATIONS
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE conversations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
contact_id UUID NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'pending', 'closed')),
|
||||||
|
assigned_agent_id UUID,
|
||||||
|
last_message_text TEXT,
|
||||||
|
last_message_at TIMESTAMPTZ,
|
||||||
|
unread_count INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_conversations_user_id ON conversations(user_id);
|
||||||
|
CREATE INDEX idx_conversations_contact_id ON conversations(contact_id);
|
||||||
|
|
||||||
|
ALTER TABLE conversations ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage own conversations" ON conversations FOR ALL USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- MESSAGES
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE messages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
||||||
|
sender_type TEXT NOT NULL CHECK (sender_type IN ('customer', 'agent', 'bot')),
|
||||||
|
sender_id UUID,
|
||||||
|
content_type TEXT NOT NULL DEFAULT 'text' CHECK (content_type IN ('text', 'image', 'document', 'audio', 'video', 'location', 'template')),
|
||||||
|
content_text TEXT,
|
||||||
|
media_url TEXT,
|
||||||
|
template_name TEXT,
|
||||||
|
message_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'sent' CHECK (status IN ('sending', 'sent', 'delivered', 'read', 'failed')),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_messages_conversation ON messages(conversation_id);
|
||||||
|
CREATE INDEX idx_messages_message_id ON messages(message_id);
|
||||||
|
|
||||||
|
ALTER TABLE messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can view own messages" ON messages FOR ALL
|
||||||
|
USING (EXISTS (SELECT 1 FROM conversations WHERE conversations.id = messages.conversation_id AND conversations.user_id = auth.uid()));
|
||||||
|
|
||||||
|
-- Service role bypass for webhook inserts
|
||||||
|
CREATE POLICY "Service role can insert messages" ON messages FOR INSERT
|
||||||
|
WITH CHECK (true);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- WHATSAPP_CONFIG
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE whatsapp_config (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
phone_number_id TEXT NOT NULL,
|
||||||
|
waba_id TEXT,
|
||||||
|
access_token TEXT NOT NULL,
|
||||||
|
verify_token TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'disconnected' CHECK (status IN ('connected', 'disconnected')),
|
||||||
|
connected_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
UNIQUE(user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE whatsapp_config ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage own config" ON whatsapp_config FOR ALL USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- MESSAGE_TEMPLATES
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE message_templates (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
category TEXT NOT NULL DEFAULT 'Marketing' CHECK (category IN ('Marketing', 'Utility', 'Authentication')),
|
||||||
|
language TEXT DEFAULT 'en_US',
|
||||||
|
header_type TEXT CHECK (header_type IN ('text', 'image', 'video', 'document')),
|
||||||
|
header_content TEXT,
|
||||||
|
body_text TEXT NOT NULL,
|
||||||
|
footer_text TEXT,
|
||||||
|
buttons JSONB,
|
||||||
|
status TEXT DEFAULT 'Draft' CHECK (status IN ('Draft', 'Pending', 'Approved', 'Rejected')),
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE message_templates ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage own templates" ON message_templates FOR ALL USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PIPELINES
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE pipelines (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE pipelines ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage own pipelines" ON pipelines FOR ALL USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- PIPELINE_STAGES
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE pipeline_stages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
pipeline_id UUID NOT NULL REFERENCES pipelines(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
color TEXT NOT NULL DEFAULT '#3b82f6',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_pipeline_stages_pipeline ON pipeline_stages(pipeline_id);
|
||||||
|
|
||||||
|
ALTER TABLE pipeline_stages ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage pipeline stages" ON pipeline_stages FOR ALL
|
||||||
|
USING (EXISTS (SELECT 1 FROM pipelines WHERE pipelines.id = pipeline_stages.pipeline_id AND pipelines.user_id = auth.uid()));
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- DEALS
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE deals (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
pipeline_id UUID NOT NULL REFERENCES pipelines(id) ON DELETE CASCADE,
|
||||||
|
stage_id UUID NOT NULL REFERENCES pipeline_stages(id),
|
||||||
|
contact_id UUID NOT NULL REFERENCES contacts(id),
|
||||||
|
conversation_id UUID REFERENCES conversations(id),
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
value NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||||
|
currency TEXT DEFAULT 'USD',
|
||||||
|
notes TEXT,
|
||||||
|
expected_close_date DATE,
|
||||||
|
status TEXT DEFAULT 'active',
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_deals_pipeline ON deals(pipeline_id);
|
||||||
|
CREATE INDEX idx_deals_stage ON deals(stage_id);
|
||||||
|
|
||||||
|
ALTER TABLE deals ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage own deals" ON deals FOR ALL USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- BROADCASTS
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE broadcasts (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
template_name TEXT NOT NULL,
|
||||||
|
template_language TEXT NOT NULL DEFAULT 'en_US',
|
||||||
|
template_variables JSONB,
|
||||||
|
audience_filter JSONB,
|
||||||
|
scheduled_at TIMESTAMPTZ,
|
||||||
|
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'scheduled', 'sending', 'sent', 'failed')),
|
||||||
|
total_recipients INTEGER DEFAULT 0,
|
||||||
|
sent_count INTEGER DEFAULT 0,
|
||||||
|
delivered_count INTEGER DEFAULT 0,
|
||||||
|
read_count INTEGER DEFAULT 0,
|
||||||
|
replied_count INTEGER DEFAULT 0,
|
||||||
|
failed_count INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE broadcasts ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage own broadcasts" ON broadcasts FOR ALL USING (auth.uid() = user_id);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- BROADCAST_RECIPIENTS
|
||||||
|
-- ============================================================
|
||||||
|
CREATE TABLE broadcast_recipients (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
broadcast_id UUID NOT NULL REFERENCES broadcasts(id) ON DELETE CASCADE,
|
||||||
|
contact_id UUID NOT NULL REFERENCES contacts(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'delivered', 'read', 'replied', 'failed')),
|
||||||
|
sent_at TIMESTAMPTZ,
|
||||||
|
delivered_at TIMESTAMPTZ,
|
||||||
|
read_at TIMESTAMPTZ,
|
||||||
|
replied_at TIMESTAMPTZ,
|
||||||
|
error_message TEXT,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_broadcast_recipients_broadcast ON broadcast_recipients(broadcast_id);
|
||||||
|
|
||||||
|
ALTER TABLE broadcast_recipients ENABLE ROW LEVEL SECURITY;
|
||||||
|
CREATE POLICY "Users can manage broadcast recipients" ON broadcast_recipients FOR ALL
|
||||||
|
USING (EXISTS (SELECT 1 FROM broadcasts WHERE broadcasts.id = broadcast_recipients.broadcast_id AND broadcasts.user_id = auth.uid()));
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- UPDATED_AT TRIGGER FUNCTION
|
||||||
|
-- ============================================================
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
-- Apply to tables with updated_at
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON profiles FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON contacts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON conversations FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON whatsapp_config FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON message_templates FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON deals FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
CREATE TRIGGER set_updated_at BEFORE UPDATE ON broadcasts FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- AUTO-CREATE PROFILE ON USER SIGNUP
|
||||||
|
-- ============================================================
|
||||||
|
CREATE OR REPLACE FUNCTION handle_new_user()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO profiles (user_id, full_name, email)
|
||||||
|
VALUES (NEW.id, COALESCE(NEW.raw_user_meta_data->>'full_name', ''), NEW.email);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||||
|
|
||||||
|
CREATE TRIGGER on_auth_user_created
|
||||||
|
AFTER INSERT ON auth.users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- ENABLE REALTIME for key tables
|
||||||
|
-- ============================================================
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
|
||||||
|
ALTER PUBLICATION supabase_realtime ADD TABLE conversations;
|
||||||
Loading…
Reference in New Issue
Block a user