shubhjn commited on
Commit
c480521
·
1 Parent(s): c0543f3

Task Deletion Scraper Improvements: UI Consistency & Safety

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. app/actions/business.ts +35 -0
  2. app/admin/(dashboard)/businesses/page.tsx +15 -0
  3. app/admin/{feedback → (dashboard)/feedback}/page.tsx +6 -6
  4. app/admin/(dashboard)/layout.tsx +105 -0
  5. app/admin/(dashboard)/page.tsx +169 -0
  6. app/admin/(dashboard)/settings/page.tsx +142 -0
  7. app/admin/{users → (dashboard)/users}/page.tsx +0 -0
  8. app/admin/actions.ts +73 -0
  9. app/admin/actions/business.ts +16 -0
  10. app/admin/businesses/page.tsx +0 -61
  11. app/admin/layout.tsx +0 -87
  12. app/admin/login/page.tsx +2 -1
  13. app/admin/page.tsx +0 -103
  14. app/api/businesses/route.ts +22 -4
  15. app/api/email/send/route.ts +102 -0
  16. app/api/scraping/control/route.ts +6 -4
  17. app/api/tasks/route.ts +37 -1
  18. app/dashboard/businesses/page.tsx +137 -420
  19. app/dashboard/layout.tsx +19 -8
  20. app/dashboard/page.tsx +96 -55
  21. app/dashboard/tasks/page.tsx +118 -274
  22. app/layout.tsx +5 -1
  23. app/providers.tsx +1 -2
  24. components/admin/admin-analytics.tsx +130 -0
  25. components/admin/admin-business-view.tsx +78 -0
  26. components/admin/feedback-actions.tsx +56 -0
  27. components/admin/quick-actions.tsx +32 -0
  28. components/animated-container.tsx +39 -0
  29. components/common/confirm-dialog.tsx +58 -0
  30. components/dashboard/business-table.tsx +61 -1
  31. components/dashboard/email-chart.tsx +56 -0
  32. components/dashboard/tasks/task-card.tsx +173 -0
  33. components/dashboard/tasks/task-column.tsx +76 -0
  34. components/demo-notifications.tsx +2 -0
  35. components/info-banner.tsx +56 -0
  36. components/notification-bell.tsx +38 -76
  37. components/ui/alert-dialog.tsx +141 -0
  38. components/ui/alert.tsx +59 -0
  39. components/ui/button.tsx +4 -2
  40. components/ui/sheet.tsx +140 -0
  41. components/ui/sonner.tsx +31 -0
  42. db/schema/index.ts +18 -0
  43. lib/scraper-real.ts +15 -10
  44. lib/scrapers/facebook.ts +78 -0
  45. lib/scrapers/index.ts +7 -4
  46. lib/scrapers/instagram.ts +83 -0
  47. lib/scrapers/linkedin.ts +91 -0
  48. package.json +3 -0
  49. pnpm-lock.yaml +47 -0
  50. store/notifications.ts +20 -7
app/actions/business.ts ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { auth } from "@/auth";
4
+ import { db } from "@/db";
5
+ import { businesses } from "@/db/schema";
6
+ import { eq, inArray, and } from "drizzle-orm";
7
+ import { revalidatePath } from "next/cache";
8
+
9
+ export async function deleteBusiness(id: string) {
10
+ const session = await auth();
11
+ if (!session?.user?.id) throw new Error("Unauthorized");
12
+
13
+ await db.delete(businesses).where(
14
+ and(
15
+ eq(businesses.id, id),
16
+ eq(businesses.userId, session.user.id)
17
+ )
18
+ );
19
+ revalidatePath("/dashboard");
20
+ revalidatePath("/dashboard/businesses");
21
+ }
22
+
23
+ export async function bulkDeleteBusinesses(ids: string[]) {
24
+ const session = await auth();
25
+ if (!session?.user?.id) throw new Error("Unauthorized");
26
+
27
+ await db.delete(businesses).where(
28
+ and(
29
+ inArray(businesses.id, ids),
30
+ eq(businesses.userId, session.user.id)
31
+ )
32
+ );
33
+ revalidatePath("/dashboard");
34
+ revalidatePath("/dashboard/businesses");
35
+ }
app/admin/(dashboard)/businesses/page.tsx ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { db } from "@/db";
2
+ import { businesses } from "@/db/schema";
3
+ import { desc } from "drizzle-orm";
4
+ import { AdminBusinessView } from "@/components/admin/admin-business-view";
5
+
6
+ export default async function AdminBusinessesPage() {
7
+ const items = await db.select().from(businesses).orderBy(desc(businesses.createdAt)).limit(100);
8
+
9
+ // Cast or map items if necessary to match Business type
10
+ // Assuming schema matches type, but dates might need string conversion if passed to client?
11
+ // Drizzle returns Date objects, Client Component expects serialized props?
12
+ // Next.js Server Components -> Client Components auto-serialize Dates.
13
+
14
+ return <AdminBusinessView initialBusinesses={items} />;
15
+ }
app/admin/{feedback → (dashboard)/feedback}/page.tsx RENAMED
@@ -4,13 +4,14 @@ import { desc, eq } from "drizzle-orm";
4
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
  import { Badge } from "@/components/ui/badge";
6
  import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
 
7
 
8
  export default async function FeedbackPage() {
9
  // Fetch feedback with user details if possible
10
  // Drizzle relations would make this easier.
11
  // Manual join or separate fetch.
12
  // For now, let's fetch feedback and map user info if needed or just show basic info.
13
-
14
  // We can do a join:
15
  const feedbacks = await db
16
  .select({
@@ -29,7 +30,7 @@ export default async function FeedbackPage() {
29
 
30
  return (
31
  <div className="space-y-6">
32
- <div>
33
  <h2 className="text-3xl font-bold tracking-tight">Feedback</h2>
34
  <p className="text-muted-foreground">User reports and suggestions</p>
35
  </div>
@@ -52,9 +53,8 @@ export default async function FeedbackPage() {
52
  <Badge variant={item.type === "bug" ? "destructive" : "default"}>
53
  {item.type}
54
  </Badge>
55
- <Badge variant="outline">
56
- {item.status}
57
- </Badge>
58
  </div>
59
  </CardHeader>
60
  <CardContent className="p-4 pt-2">
@@ -66,6 +66,6 @@ export default async function FeedbackPage() {
66
  </Card>
67
  ))}
68
  </div>
69
- </div>
70
  )
71
  }
 
4
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
  import { Badge } from "@/components/ui/badge";
6
  import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
7
+ import { FeedbackActions } from "@/components/admin/feedback-actions";
8
 
9
  export default async function FeedbackPage() {
10
  // Fetch feedback with user details if possible
11
  // Drizzle relations would make this easier.
12
  // Manual join or separate fetch.
13
  // For now, let's fetch feedback and map user info if needed or just show basic info.
14
+
15
  // We can do a join:
16
  const feedbacks = await db
17
  .select({
 
30
 
31
  return (
32
  <div className="space-y-6">
33
+ <div>
34
  <h2 className="text-3xl font-bold tracking-tight">Feedback</h2>
35
  <p className="text-muted-foreground">User reports and suggestions</p>
36
  </div>
 
53
  <Badge variant={item.type === "bug" ? "destructive" : "default"}>
54
  {item.type}
55
  </Badge>
56
+ <Badge>{item.status}</Badge>
57
+ <FeedbackActions id={item.id} status={item.status} />
 
58
  </div>
59
  </CardHeader>
60
  <CardContent className="p-4 pt-2">
 
66
  </Card>
67
  ))}
68
  </div>
69
+ </div >
70
  )
71
  }
app/admin/(dashboard)/layout.tsx ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { redirect } from "next/navigation";
2
+ import { auth } from "@/auth";
3
+ import Link from "next/link";
4
+ import { LayoutDashboard, MessageSquare, Users, Settings, LogOut, Shield, Building2, Menu } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
7
+
8
+ const SidebarContent = () => (
9
+ <div className="flex flex-col h-full">
10
+ <div className="p-6 border-b flex items-center gap-2">
11
+ <Shield className="h-6 w-6 text-primary" />
12
+ <h1 className="font-bold text-xl tracking-tight">Admin</h1>
13
+ </div>
14
+
15
+ <nav className="flex-1 p-4 space-y-1">
16
+ <Link href="/admin" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
17
+ <LayoutDashboard className="h-4 w-4" />
18
+ Dashboard
19
+ </Link>
20
+ <Link href="/admin/feedback" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
21
+ <MessageSquare className="h-4 w-4" />
22
+ Feedback
23
+ </Link>
24
+ <Link href="/admin/businesses" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
25
+ <Building2 className="h-4 w-4" />
26
+ Businesses
27
+ </Link>
28
+ <Link href="/admin/users" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
29
+ <Users className="h-4 w-4" />
30
+ Users
31
+ </Link>
32
+ <Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
33
+ <Settings className="h-4 w-4" />
34
+ Settings
35
+ </Link>
36
+ </nav>
37
+
38
+ <div className="p-4 border-t">
39
+ <form action={async () => {
40
+ "use server"
41
+ }}>
42
+ <Button variant="ghost" className="w-full justify-start text-red-500 hover:text-red-600 hover:bg-red-50 disabled:opacity-50" asChild>
43
+ <Link href="/api/auth/signout">
44
+ <LogOut className="mr-2 h-4 w-4" />
45
+ Sign Out
46
+ </Link>
47
+ </Button>
48
+ </form>
49
+ </div>
50
+ </div>
51
+ );
52
+
53
+ export default async function AdminLayout({
54
+ children,
55
+ }: {
56
+ children: React.ReactNode;
57
+ }) {
58
+ const session = await auth();
59
+
60
+ if (session?.user?.role !== "admin") {
61
+ redirect("/admin/login");
62
+ }
63
+
64
+ return (
65
+ <div className="flex h-screen bg-muted/40">
66
+ {/* Desktop Sidebar */}
67
+ <aside className="w-64 bg-background border-r shadow-sm hidden md:flex flex-col">
68
+ <SidebarContent />
69
+ </aside>
70
+
71
+ {/* Main Content */}
72
+ <div className="flex-1 flex flex-col overflow-hidden">
73
+ {/* Navbar */}
74
+ <header className="h-16 bg-background border-b flex items-center justify-between px-6 shadow-sm">
75
+ <div className="flex items-center gap-4">
76
+ {/* Mobile Sidebar Trigger */}
77
+ <div className="md:hidden">
78
+ <Sheet>
79
+ <SheetTrigger asChild>
80
+ <Button variant="ghost" size="icon">
81
+ <Menu className="h-5 w-5" />
82
+ </Button>
83
+ </SheetTrigger>
84
+ <SheetContent side="left" className="p-0 w-64">
85
+ <SidebarContent />
86
+ </SheetContent>
87
+ </Sheet>
88
+ </div>
89
+
90
+ <h2 className="text-sm font-medium text-muted-foreground hidden sm:block">Welcome, {session.user.name}</h2>
91
+ </div>
92
+ <div className="flex items-center gap-4">
93
+ <Button variant="outline" size="sm" asChild>
94
+ <Link href="/dashboard">Go to App</Link>
95
+ </Button>
96
+ </div>
97
+ </header>
98
+
99
+ <main className="flex-1 overflow-auto p-6">
100
+ {children}
101
+ </main>
102
+ </div>
103
+ </div>
104
+ );
105
+ }
app/admin/(dashboard)/page.tsx ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { db } from "@/db";
2
+ import { users, businesses, automationWorkflows, scrapingJobs } from "@/db/schema";
3
+ import { count, eq } from "drizzle-orm";
4
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
+ import { Users, Building2, Workflow, Activity } from "lucide-react";
6
+ import { AdminAnalytics } from "@/components/admin/admin-analytics";
7
+ import { QuickActions } from "@/components/admin/quick-actions";
8
+ import { AnimatedContainer } from "@/components/animated-container";
9
+
10
+ // import dynamic from "next/dynamic";
11
+ /*
12
+ const AdminAnalytics = dynamic(() => import("@/components/admin/admin-analytics").then(mod => mod.AdminAnalytics), {
13
+ loading: () => <div className="h-[300px] bg-muted animate-pulse rounded" />,
14
+ ssr: false
15
+ });
16
+ */
17
+
18
+ import { PgTable } from "drizzle-orm/pg-core";
19
+
20
+ async function getStats() {
21
+ // Helper to get count safely
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ const getCount = async (table: PgTable<any>) => {
24
+ const res = await db.select({ value: count() }).from(table);
25
+ return res[0].value;
26
+ }
27
+
28
+ const [userCount, businessCount, workflowCount, jobCount] = await Promise.all([
29
+ getCount(users),
30
+ getCount(businesses),
31
+ getCount(automationWorkflows),
32
+ getCount(scrapingJobs)
33
+ ]);
34
+
35
+ // Active scrapers (status = running)
36
+ const activeJobsRes = await db.select({ value: count() }).from(scrapingJobs).where(eq(scrapingJobs.status, "running"));
37
+ const activeJobs = activeJobsRes[0].value;
38
+
39
+ return {
40
+ userCount,
41
+ businessCount,
42
+ workflowCount,
43
+ jobCount,
44
+ activeJobs
45
+ };
46
+ }
47
+
48
+ // Helper function to fetch analytic data
49
+ async function getAnalyticsData() {
50
+ // 1. User Growth (Last 30 days)
51
+ const allUsers = await db.select({ createdAt: users.createdAt }).from(users);
52
+
53
+ const growthMap = new Map<string, number>();
54
+ allUsers.forEach(u => {
55
+ if (!u.createdAt) return;
56
+ const date = u.createdAt.toISOString().split('T')[0];
57
+ growthMap.set(date, (growthMap.get(date) || 0) + 1);
58
+ });
59
+
60
+ const userGrowth = Array.from(growthMap.entries())
61
+ .map(([date, count]) => ({ date, count }))
62
+ .sort((a, b) => a.date.localeCompare(b.date))
63
+ .slice(-30);
64
+
65
+ // 2. Business Categories & Email Status
66
+ const allBusinesses = await db.select({
67
+ category: businesses.category,
68
+ emailStatus: businesses.emailStatus
69
+ }).from(businesses);
70
+
71
+ const categoryMap = new Map<string, number>();
72
+ const statusMap = new Map<string, number>();
73
+
74
+ allBusinesses.forEach(b => {
75
+ if (b.category) categoryMap.set(b.category, (categoryMap.get(b.category) || 0) + 1);
76
+ const status = b.emailStatus || "Unknown";
77
+ statusMap.set(status, (statusMap.get(status) || 0) + 1);
78
+ });
79
+
80
+ const businessCategories = Array.from(categoryMap.entries()).map(([name, value]) => ({ name, value }));
81
+ const businessStatus = Array.from(statusMap.entries()).map(([name, value]) => ({ name, value }));
82
+
83
+ return { userGrowth, businessCategories, businessStatus };
84
+ }
85
+
86
+ export default async function AdminDashboardPage() {
87
+ const stats = await getStats();
88
+ const analyticsData = await getAnalyticsData();
89
+
90
+ return (
91
+ <div className="space-y-8">
92
+ <div>
93
+ <h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
94
+ <p className="text-muted-foreground">System overview and key metrics</p>
95
+ </div>
96
+
97
+ <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
98
+ <AnimatedContainer delay={0.1}>
99
+ <Card>
100
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
101
+ <CardTitle className="text-sm font-medium">Total Users</CardTitle>
102
+ <Users className="h-4 w-4 text-muted-foreground" />
103
+ </CardHeader>
104
+ <CardContent>
105
+ <div className="text-2xl font-bold">{stats.userCount}</div>
106
+ <p className="text-xs text-muted-foreground">Registered accounts</p>
107
+ </CardContent>
108
+ </Card>
109
+ </AnimatedContainer>
110
+
111
+ <AnimatedContainer delay={0.2}>
112
+ <Card>
113
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
114
+ <CardTitle className="text-sm font-medium">Businesses Scraped</CardTitle>
115
+ <Building2 className="h-4 w-4 text-muted-foreground" />
116
+ </CardHeader>
117
+ <CardContent>
118
+ <div className="text-2xl font-bold">{stats.businessCount}</div>
119
+ <p className="text-xs text-muted-foreground">Total leads database</p>
120
+ </CardContent>
121
+ </Card>
122
+ </AnimatedContainer>
123
+
124
+ <AnimatedContainer delay={0.3}>
125
+ <Card>
126
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
127
+ <CardTitle className="text-sm font-medium">Active Workflows</CardTitle>
128
+ <Workflow className="h-4 w-4 text-muted-foreground" />
129
+ </CardHeader>
130
+ <CardContent>
131
+ <div className="text-2xl font-bold">{stats.workflowCount}</div>
132
+ <p className="text-xs text-muted-foreground">Automated sequences</p>
133
+ </CardContent>
134
+ </Card>
135
+ </AnimatedContainer>
136
+
137
+ <AnimatedContainer delay={0.4}>
138
+ <Card>
139
+ <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
140
+ <CardTitle className="text-sm font-medium">Active Jobs</CardTitle>
141
+ <Activity className="h-4 w-4 text-muted-foreground" />
142
+ </CardHeader>
143
+ <CardContent>
144
+ <div className="text-2xl font-bold">{stats.activeJobs}</div>
145
+ <p className="text-xs text-muted-foreground">Currently running tasks</p>
146
+ </CardContent>
147
+ </Card>
148
+ </AnimatedContainer>
149
+ </div>
150
+
151
+ <div className="grid gap-4 md:grid-cols-7">
152
+ <div className="col-span-4">
153
+ <AnimatedContainer delay={0.5} direction="left">
154
+ <AdminAnalytics
155
+ userGrowth={analyticsData.userGrowth}
156
+ businessCategories={analyticsData.businessCategories}
157
+ businessStatus={analyticsData.businessStatus}
158
+ />
159
+ </AnimatedContainer>
160
+ </div>
161
+ <div className="col-span-3">
162
+ <AnimatedContainer delay={0.6} direction="right">
163
+ <QuickActions />
164
+ </AnimatedContainer>
165
+ </div>
166
+ </div>
167
+ </div>
168
+ );
169
+ }
app/admin/(dashboard)/settings/page.tsx ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { auth } from "@/auth";
2
+ import { db } from "@/db";
3
+ import { banners } from "@/db/schema";
4
+ import { desc } from "drizzle-orm";
5
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
6
+ import { Button } from "@/components/ui/button";
7
+ import { Input } from "@/components/ui/input";
8
+ import { Label } from "@/components/ui/label";
9
+ import { Textarea } from "@/components/ui/textarea";
10
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
11
+ import { sendGlobalNotification, createBanner, toggleBanner, deleteBanner } from "@/app/admin/actions";
12
+ import { Switch } from "@/components/ui/switch";
13
+ import { Trash2 } from "lucide-react";
14
+
15
+ export default async function SettingsPage() {
16
+ const session = await auth();
17
+ if (session?.user?.role !== "admin") return <div>Unauthorized</div>;
18
+
19
+ const allBanners = await db.select().from(banners).orderBy(desc(banners.createdAt));
20
+
21
+ return (
22
+ <div className="space-y-6">
23
+ <div>
24
+ <h2 className="text-3xl font-bold tracking-tight">Settings</h2>
25
+ <p className="text-muted-foreground">Manage platform configurations</p>
26
+ </div>
27
+
28
+ <Tabs defaultValue="notifications" className="space-y-4">
29
+ <TabsList>
30
+ <TabsTrigger value="notifications">Notifications</TabsTrigger>
31
+ <TabsTrigger value="banners">Info Banners</TabsTrigger>
32
+ <TabsTrigger value="changelog">Change Log</TabsTrigger>
33
+ </TabsList>
34
+
35
+ <TabsContent value="notifications">
36
+ <Card>
37
+ <CardHeader>
38
+ <CardTitle>Global Push Notification</CardTitle>
39
+ <CardDescription>Send a notification to all user dashboards.</CardDescription>
40
+ </CardHeader>
41
+ <CardContent>
42
+ <form action={async (formData) => {
43
+ "use server";
44
+ await sendGlobalNotification(formData);
45
+ }} className="space-y-4 max-w-lg">
46
+ <div className="space-y-2">
47
+ <Label htmlFor="title">Title</Label>
48
+ <Input id="title" name="title" placeholder="e.g. System Update" required />
49
+ </div>
50
+ <div className="space-y-2">
51
+ <Label htmlFor="message">Message</Label>
52
+ <Textarea id="message" name="message" placeholder="Details..." required />
53
+ </div>
54
+ <div className="space-y-2">
55
+ <Label htmlFor="type">Type</Label>
56
+ <select name="type" className="w-full p-2 border rounded-md bg-background">
57
+ <option value="info">Info</option>
58
+ <option value="warning">Warning</option>
59
+ <option value="success">Success</option>
60
+ </select>
61
+ </div>
62
+ <Button type="submit">Send Broadcast</Button>
63
+ </form>
64
+ </CardContent>
65
+ </Card>
66
+ </TabsContent>
67
+
68
+ <TabsContent value="banners">
69
+ <div className="grid gap-6">
70
+ <Card>
71
+ <CardHeader>
72
+ <CardTitle>Create Info Banner</CardTitle>
73
+ <CardDescription>This will appear as a marquee on user dashboards.</CardDescription>
74
+ </CardHeader>
75
+ <CardContent>
76
+ <form action={async (formData) => {
77
+ "use server";
78
+ await createBanner(formData);
79
+ }} className="space-y-4 max-w-lg">
80
+ <div className="space-y-2">
81
+ <Label htmlFor="message">Banner Message</Label>
82
+ <Input id="message" name="message" placeholder="e.g. 50% Off Promo!" required />
83
+ </div>
84
+ <Button type="submit">Create Banner</Button>
85
+ </form>
86
+ </CardContent>
87
+ </Card>
88
+
89
+ <Card>
90
+ <CardHeader>
91
+ <CardTitle>Active Banners</CardTitle>
92
+ </CardHeader>
93
+ <CardContent>
94
+ <div className="space-y-4">
95
+ {allBanners.map((banner) => (
96
+ <div key={banner.id} className="flex items-center justify-between p-4 border rounded-lg">
97
+ <div>
98
+ <p className="font-medium">{banner.message}</p>
99
+ <p className="text-xs text-muted-foreground">{banner.createdAt.toLocaleDateString()}</p>
100
+ </div>
101
+ <div className="flex items-center gap-4">
102
+ <form action={async () => {
103
+ "use server"
104
+ await toggleBanner(banner.id, !banner.isActive)
105
+ }}>
106
+ <div className="flex items-center gap-2">
107
+ <Switch checked={banner.isActive} type="submit" />
108
+ <span className="text-sm">{banner.isActive ? "Active" : "Inactive"}</span>
109
+ </div>
110
+ </form>
111
+ <form action={async () => {
112
+ "use server"
113
+ await deleteBanner(banner.id)
114
+ }}>
115
+ <Button size="icon" variant="ghost" className="text-destructive">
116
+ <Trash2 className="h-4 w-4" />
117
+ </Button>
118
+ </form>
119
+ </div>
120
+ </div>
121
+ ))}
122
+ </div>
123
+ </CardContent>
124
+ </Card>
125
+ </div>
126
+ </TabsContent>
127
+
128
+ <TabsContent value="changelog">
129
+ <Card>
130
+ <CardHeader>
131
+ <CardTitle>Platform Change Log</CardTitle>
132
+ <CardDescription>Track updates and version history (Coming Soon)</CardDescription>
133
+ </CardHeader>
134
+ <CardContent>
135
+ <p className="text-sm text-muted-foreground">Change log management will be implemented in the next phase.</p>
136
+ </CardContent>
137
+ </Card>
138
+ </TabsContent>
139
+ </Tabs>
140
+ </div>
141
+ );
142
+ }
app/admin/{users → (dashboard)/users}/page.tsx RENAMED
File without changes
app/admin/actions.ts ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { auth } from "@/auth";
4
+ import { db } from "@/db";
5
+ import { notifications, banners } from "@/db/schema";
6
+ import { eq } from "drizzle-orm";
7
+ import { revalidatePath } from "next/cache";
8
+
9
+ // --- Global Notifications ---
10
+
11
+ export async function sendGlobalNotification(formData: FormData) {
12
+ const session = await auth();
13
+ if (session?.user?.role !== "admin") throw new Error("Unauthorized");
14
+
15
+ const title = formData.get("title") as string;
16
+ const message = formData.get("message") as string;
17
+ const type = formData.get("type") as "info" | "warning" | "success" || "info";
18
+
19
+ if (!title || !message) return { error: "Missing fields" };
20
+
21
+ await db.insert(notifications).values({
22
+ title,
23
+ message,
24
+ type,
25
+ // userId: null implies global notification
26
+ userId: null,
27
+ });
28
+
29
+ revalidatePath("/dashboard"); // Revalidate user dashboard to show new notif
30
+ return { success: true };
31
+ }
32
+
33
+ // --- Banners ---
34
+
35
+ export async function createBanner(formData: FormData) {
36
+ const session = await auth();
37
+ if (session?.user?.role !== "admin") throw new Error("Unauthorized");
38
+
39
+ const message = formData.get("message") as string;
40
+ if (!message) return { error: "Message required" };
41
+
42
+ // Deactivate other banners if we want only one active?
43
+ // User didn't specify, but usually marquee is one at a time or list.
44
+ // I'll leave others active unless requested.
45
+
46
+ await db.insert(banners).values({
47
+ message,
48
+ isActive: true,
49
+ createdBy: session.user.id,
50
+ });
51
+
52
+ revalidatePath("/dashboard");
53
+ return { success: true };
54
+ }
55
+
56
+ export async function toggleBanner(id: string, isActive: boolean) {
57
+ const session = await auth();
58
+ if (session?.user?.role !== "admin") throw new Error("Unauthorized");
59
+
60
+ await db.update(banners).set({ isActive }).where(eq(banners.id, id));
61
+ revalidatePath("/dashboard");
62
+ revalidatePath("/admin/settings");
63
+ return { success: true };
64
+ }
65
+
66
+ export async function deleteBanner(id: string) {
67
+ const session = await auth();
68
+ if (session?.user?.role !== "admin") throw new Error("Unauthorized");
69
+
70
+ await db.delete(banners).where(eq(banners.id, id));
71
+ revalidatePath("/admin/settings");
72
+ return { success: true };
73
+ }
app/admin/actions/business.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use server";
2
+
3
+ import { auth } from "@/auth";
4
+ import { db } from "@/db";
5
+ import { businesses } from "@/db/schema";
6
+ import { inArray } from "drizzle-orm";
7
+ import { revalidatePath } from "next/cache";
8
+
9
+ // Admin Bulk Delete (Any user)
10
+ export async function adminBulkDeleteBusinesses(ids: string[]) {
11
+ const session = await auth();
12
+ if (session?.user?.role !== "admin") throw new Error("Unauthorized");
13
+
14
+ await db.delete(businesses).where(inArray(businesses.id, ids));
15
+ revalidatePath("/admin/businesses");
16
+ }
app/admin/businesses/page.tsx DELETED
@@ -1,61 +0,0 @@
1
- import { db } from "@/db";
2
- import { businesses } from "@/db/schema";
3
- import { desc } from "drizzle-orm";
4
- import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
5
- import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
- import { Badge } from "@/components/ui/badge";
7
-
8
- export default async function AdminBusinessesPage() {
9
- const items = await db.select().from(businesses).orderBy(desc(businesses.createdAt)).limit(100);
10
-
11
- return (
12
- <div className="space-y-6">
13
- <div>
14
- <h2 className="text-3xl font-bold tracking-tight">Businesses</h2>
15
- <p className="text-muted-foreground">Manage scraped leads and business data</p>
16
- </div>
17
-
18
- <Card>
19
- <CardHeader>
20
- <CardTitle>Recent Leads ({items.length})</CardTitle>
21
- </CardHeader>
22
- <CardContent>
23
- <Table>
24
- <TableHeader>
25
- <TableRow>
26
- <TableHead>Name</TableHead>
27
- <TableHead>Category</TableHead>
28
- <TableHead>Contact</TableHead>
29
- <TableHead>Status</TableHead>
30
- </TableRow>
31
- </TableHeader>
32
- <TableBody>
33
- {items.map((item) => (
34
- <TableRow key={item.id}>
35
- <TableCell className="font-medium">
36
- {item.name}
37
- {item.website && (
38
- <a href={item.website} target="_blank" rel="noreferrer" className="block text-xs text-blue-500 hover:underline truncate max-w-[200px]">
39
- {item.website}
40
- </a>
41
- )}
42
- </TableCell>
43
- <TableCell>{item.category}</TableCell>
44
- <TableCell>
45
- <div className="text-sm">{item.email || "-"}</div>
46
- <div className="text-xs text-muted-foreground">{item.phone || "-"}</div>
47
- </TableCell>
48
- <TableCell>
49
- <Badge variant={item.emailSent ? "secondary" : "default"}>
50
- {item.emailSent ? "Contacted" : "New"}
51
- </Badge>
52
- </TableCell>
53
- </TableRow>
54
- ))}
55
- </TableBody>
56
- </Table>
57
- </CardContent>
58
- </Card>
59
- </div>
60
- );
61
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/admin/layout.tsx DELETED
@@ -1,87 +0,0 @@
1
- import { redirect } from "next/navigation";
2
- import { auth } from "@/auth";
3
- import Link from "next/link";
4
- import { LayoutDashboard, MessageSquare, Users, Settings, LogOut, Shield, Building2 } from "lucide-react";
5
- import { Button } from "@/components/ui/button";
6
-
7
- export default async function AdminLayout({
8
- children,
9
- }: {
10
- children: React.ReactNode;
11
- }) {
12
- const session = await auth();
13
-
14
- if (session?.user?.role !== "admin") {
15
- redirect("/admin/login");
16
- }
17
-
18
- return (
19
- <div className="flex h-screen bg-gray-100 dark:bg-gray-900">
20
- {/* Admin Sidebar */}
21
- <aside className="w-64 bg-card border-r shadow-sm hidden md:flex flex-col">
22
- <div className="p-6 border-b flex items-center gap-2">
23
- <Shield className="h-6 w-6 text-primary" />
24
- <h1 className="font-bold text-xl tracking-tight">Admin</h1>
25
- </div>
26
-
27
- <nav className="flex-1 p-4 space-y-1">
28
- <Link href="/admin" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
29
- <LayoutDashboard className="h-4 w-4" />
30
- Dashboard
31
- </Link>
32
- <Link href="/admin/feedback" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
33
- <MessageSquare className="h-4 w-4" />
34
- Feedback
35
- </Link>
36
- <Link href="/admin/businesses" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
37
- <Building2 className="h-4 w-4" />
38
- Businesses
39
- </Link>
40
- <Link href="/admin/users" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
41
- <Users className="h-4 w-4" />
42
- Users
43
- </Link>
44
- <Link href="/admin/settings" className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-md hover:bg-accent hover:text-accent-foreground transition-colors">
45
- <Settings className="h-4 w-4" />
46
- Settings
47
- </Link>
48
- </nav>
49
-
50
- <div className="p-4 border-t">
51
- <form action={async () => {
52
- "use server"
53
- // signOut handled by auth form usually, or client component.
54
- // For server component layout we might need a client wrapper for signout button or redirect.
55
- // Simplifying:
56
- }}>
57
- <Button variant="ghost" className="w-full justify-start text-red-500 hover:text-red-600 hover:bg-red-50 disabled:opacity-50" asChild>
58
- <Link href="/api/auth/signout">
59
- <LogOut className="mr-2 h-4 w-4" />
60
- Sign Out
61
- </Link>
62
- </Button>
63
- </form>
64
- </div>
65
- </aside>
66
-
67
- {/* Main Content */}
68
- <div className="flex-1 flex flex-col overflow-hidden">
69
- {/* Navbar */}
70
- <header className="h-16 bg-card border-b flex items-center justify-between px-6 shadow-sm">
71
- <div className="flex items-center gap-4">
72
- <h2 className="text-sm font-medium text-muted-foreground">Welcome, {session.user.name}</h2>
73
- </div>
74
- <div className="flex items-center gap-4">
75
- <Button variant="outline" size="sm" asChild>
76
- <Link href="/dashboard">Go to App</Link>
77
- </Button>
78
- </div>
79
- </header>
80
-
81
- <main className="flex-1 overflow-auto p-6">
82
- {children}
83
- </main>
84
- </div>
85
- </div>
86
- );
87
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/admin/login/page.tsx CHANGED
@@ -25,7 +25,7 @@ export default function AdminLoginPage() {
25
 
26
  try {
27
  const result = await signIn("credentials", {
28
- email,
29
  password,
30
  redirect: false,
31
  });
@@ -39,6 +39,7 @@ export default function AdminLoginPage() {
39
  }
40
  } catch (error) {
41
  toast.error("An error occurred");
 
42
  } finally {
43
  setLoading(false);
44
  }
 
25
 
26
  try {
27
  const result = await signIn("credentials", {
28
+ email:email.toLowerCase(),
29
  password,
30
  redirect: false,
31
  });
 
39
  }
40
  } catch (error) {
41
  toast.error("An error occurred");
42
+ console.log(`error: ${error}`);
43
  } finally {
44
  setLoading(false);
45
  }
app/admin/page.tsx DELETED
@@ -1,103 +0,0 @@
1
- import { db } from "@/db";
2
- import { users, businesses, automationWorkflows, scrapingJobs } from "@/db/schema";
3
- import { count, eq } from "drizzle-orm";
4
- import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
- import { Users, Building2, Workflow, Database, Activity } from "lucide-react";
6
-
7
- async function getStats() {
8
- // Helper to get count safely
9
- const getCount = async (table: any) => {
10
- const res = await db.select({ value: count() }).from(table);
11
- return res[0].value;
12
- }
13
-
14
- const [userCount, businessCount, workflowCount, jobCount] = await Promise.all([
15
- getCount(users),
16
- getCount(businesses),
17
- getCount(automationWorkflows),
18
- getCount(scrapingJobs)
19
- ]);
20
-
21
- // Active scrapers (status = running)
22
- const activeJobsRes = await db.select({ value: count() }).from(scrapingJobs).where(eq(scrapingJobs.status, "running"));
23
- const activeJobs = activeJobsRes[0].value;
24
-
25
- return {
26
- userCount,
27
- businessCount,
28
- workflowCount,
29
- jobCount,
30
- activeJobs
31
- };
32
- }
33
-
34
- export default async function AdminDashboardPage() {
35
- const stats = await getStats();
36
-
37
- return (
38
- <div className="space-y-8">
39
- <div>
40
- <h2 className="text-3xl font-bold tracking-tight">Dashboard</h2>
41
- <p className="text-muted-foreground">System overview and key metrics</p>
42
- </div>
43
-
44
- <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
45
- <Card>
46
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
47
- <CardTitle className="text-sm font-medium">Total Users</CardTitle>
48
- <Users className="h-4 w-4 text-muted-foreground" />
49
- </CardHeader>
50
- <CardContent>
51
- <div className="text-2xl font-bold">{stats.userCount}</div>
52
- <p className="text-xs text-muted-foreground">Registered accounts</p>
53
- </CardContent>
54
- </Card>
55
-
56
- <Card>
57
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
58
- <CardTitle className="text-sm font-medium">Businesses Scraped</CardTitle>
59
- <Building2 className="h-4 w-4 text-muted-foreground" />
60
- </CardHeader>
61
- <CardContent>
62
- <div className="text-2xl font-bold">{stats.businessCount}</div>
63
- <p className="text-xs text-muted-foreground">Total leads database</p>
64
- </CardContent>
65
- </Card>
66
-
67
- <Card>
68
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
69
- <CardTitle className="text-sm font-medium">Active Workflows</CardTitle>
70
- <Workflow className="h-4 w-4 text-muted-foreground" />
71
- </CardHeader>
72
- <CardContent>
73
- <div className="text-2xl font-bold">{stats.workflowCount}</div>
74
- <p className="text-xs text-muted-foreground">Automated sequences</p>
75
- </CardContent>
76
- </Card>
77
-
78
- <Card>
79
- <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
80
- <CardTitle className="text-sm font-medium">Active Jobs</CardTitle>
81
- <Activity className="h-4 w-4 text-muted-foreground" />
82
- </CardHeader>
83
- <CardContent>
84
- <div className="text-2xl font-bold">{stats.activeJobs}</div>
85
- <p className="text-xs text-muted-foreground">Currently running tasks</p>
86
- </CardContent>
87
- </Card>
88
- </div>
89
-
90
- {/* Activity Graph Placeholder - Could be implemented with Recharts if needed */}
91
- <Card className="col-span-4">
92
- <CardHeader>
93
- <CardTitle>Recent Activity</CardTitle>
94
- </CardHeader>
95
- <CardContent className="pl-2">
96
- <div className="h-[200px] flex items-center justify-center text-muted-foreground border-2 border-dashed rounded-md">
97
- Activity Graph Component to be added with Recharts
98
- </div>
99
- </CardContent>
100
- </Card>
101
- </div>
102
- );
103
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/api/businesses/route.ts CHANGED
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
2
  import { auth } from "@/lib/auth";
3
  import { db } from "@/db";
4
  import { businesses } from "@/db/schema";
5
- import { eq, and } from "drizzle-orm";
6
  import { rateLimit } from "@/lib/rate-limit";
7
 
8
  interface SessionUser {
@@ -22,6 +22,9 @@ export async function GET(request: Request) {
22
  const { searchParams } = new URL(request.url);
23
  const category = searchParams.get("category");
24
  const status = searchParams.get("status");
 
 
 
25
 
26
  // Build where conditions
27
  const conditions = [eq(businesses.userId, userId)];
@@ -34,14 +37,29 @@ export async function GET(request: Request) {
34
  conditions.push(eq(businesses.emailStatus, status));
35
  }
36
 
 
 
 
 
 
 
 
 
37
  const results = await db
38
  .select()
39
  .from(businesses)
40
  .where(and(...conditions))
41
  .orderBy(businesses.createdAt)
42
- .limit(100);
43
-
44
- return NextResponse.json({ businesses: results });
 
 
 
 
 
 
 
45
  } catch (error) {
46
  console.error("Error fetching businesses:", error);
47
  return NextResponse.json(
 
2
  import { auth } from "@/lib/auth";
3
  import { db } from "@/db";
4
  import { businesses } from "@/db/schema";
5
+ import { eq, and, sql } from "drizzle-orm";
6
  import { rateLimit } from "@/lib/rate-limit";
7
 
8
  interface SessionUser {
 
22
  const { searchParams } = new URL(request.url);
23
  const category = searchParams.get("category");
24
  const status = searchParams.get("status");
25
+ const page = parseInt(searchParams.get("page") || "1");
26
+ const limit = parseInt(searchParams.get("limit") || "10");
27
+ const offset = (page - 1) * limit;
28
 
29
  // Build where conditions
30
  const conditions = [eq(businesses.userId, userId)];
 
37
  conditions.push(eq(businesses.emailStatus, status));
38
  }
39
 
40
+ // Get total count
41
+ const [{ count }] = await db
42
+ .select({ count: sql<number>`count(*)` })
43
+ .from(businesses)
44
+ .where(and(...conditions));
45
+
46
+ const totalPages = Math.ceil(count / limit);
47
+
48
  const results = await db
49
  .select()
50
  .from(businesses)
51
  .where(and(...conditions))
52
  .orderBy(businesses.createdAt)
53
+ .limit(limit)
54
+ .offset(offset);
55
+
56
+ return NextResponse.json({
57
+ businesses: results,
58
+ page,
59
+ limit,
60
+ total: count,
61
+ totalPages
62
+ });
63
  } catch (error) {
64
  console.error("Error fetching businesses:", error);
65
  return NextResponse.json(
app/api/email/send/route.ts ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { auth } from "@/lib/auth";
3
+ import { db } from "@/db";
4
+ import { businesses, emailTemplates, emailLogs } from "@/db/schema";
5
+ import { eq, and } from "drizzle-orm";
6
+ import { sendColdEmail, interpolateTemplate } from "@/lib/email";
7
+ import { SessionUser } from "@/types";
8
+
9
+ export async function POST(request: Request) {
10
+ try {
11
+ const session = await auth();
12
+ if (!session?.user) {
13
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
14
+ }
15
+
16
+ const { businessId } = await request.json();
17
+
18
+ if (!businessId) {
19
+ return NextResponse.json(
20
+ { error: "Business ID required" },
21
+ { status: 400 }
22
+ );
23
+ }
24
+
25
+ const user = session.user as SessionUser;
26
+
27
+ // Check if user has connected Google Account (access token)
28
+ if (!user.accessToken) {
29
+ return NextResponse.json(
30
+ { error: "Google Account not connected. Please sign out and sign in with Google." },
31
+ { status: 403 }
32
+ );
33
+ }
34
+
35
+ // Fetch business
36
+ const business = await db.query.businesses.findFirst({
37
+ where: and(
38
+ eq(businesses.id, businessId),
39
+ eq(businesses.userId, user.id)
40
+ ),
41
+ });
42
+
43
+ if (!business) {
44
+ return NextResponse.json({ error: "Business not found" }, { status: 404 });
45
+ }
46
+
47
+ // Fetch default template
48
+ const template = await db.query.emailTemplates.findFirst({
49
+ where: and(
50
+ eq(emailTemplates.userId, user.id),
51
+ eq(emailTemplates.isDefault, true)
52
+ ),
53
+ });
54
+
55
+ if (!template) {
56
+ return NextResponse.json(
57
+ { error: "No default email template found. Please set one in Settings > Templates." },
58
+ { status: 400 }
59
+ );
60
+ }
61
+
62
+ // Send email
63
+ const success = await sendColdEmail(business, template, user.accessToken);
64
+
65
+ // Update business status
66
+ await db
67
+ .update(businesses)
68
+ .set({
69
+ emailSent: true,
70
+ emailSentAt: new Date(),
71
+ emailStatus: success ? "sent" : "failed",
72
+ updatedAt: new Date(),
73
+ })
74
+ .where(eq(businesses.id, businessId));
75
+
76
+ // Log email
77
+ await db.insert(emailLogs).values({
78
+ userId: user.id,
79
+ businessId: business.id,
80
+ templateId: template.id,
81
+ subject: interpolateTemplate(template.subject, business),
82
+ body: interpolateTemplate(template.body, business),
83
+ status: success ? "sent" : "failed",
84
+ sentAt: success ? new Date() : null,
85
+ });
86
+
87
+ if (!success) {
88
+ return NextResponse.json(
89
+ { error: "Failed to send email via Gmail API" },
90
+ { status: 500 }
91
+ );
92
+ }
93
+
94
+ return NextResponse.json({ success: true, emailStatus: "sent" });
95
+ } catch (error) {
96
+ console.error("Error sending email:", error);
97
+ return NextResponse.json(
98
+ { error: "Internal server error" },
99
+ { status: 500 }
100
+ );
101
+ }
102
+ }
app/api/scraping/control/route.ts CHANGED
@@ -13,7 +13,8 @@ export async function POST(request: Request) {
13
  }
14
 
15
  const userId = (session.user as SessionUser).id;
16
- const { jobId, action } = await request.json();
 
17
 
18
  if (!jobId || !action) {
19
  return NextResponse.json(
@@ -22,9 +23,10 @@ export async function POST(request: Request) {
22
  );
23
  }
24
 
25
- if (!["pause", "resume", "stop"].includes(action)) {
 
26
  return NextResponse.json(
27
- { error: "Invalid action. Must be 'pause', 'resume', or 'stop'" },
28
  { status: 400 }
29
  );
30
  }
@@ -61,7 +63,7 @@ export async function POST(request: Request) {
61
  }
62
  } else if (action === "set-priority") {
63
  // Handle priority update
64
- const { priority } = await request.json();
65
  if (!priority || !["low", "medium", "high"].includes(priority)) {
66
  return NextResponse.json({ error: "Invalid priority" }, { status: 400 });
67
  }
 
13
  }
14
 
15
  const userId = (session.user as SessionUser).id;
16
+ const body = await request.json();
17
+ const { jobId, action } = body;
18
 
19
  if (!jobId || !action) {
20
  return NextResponse.json(
 
23
  );
24
  }
25
 
26
+ const allowedActions = ["pause", "resume", "stop", "set-priority"];
27
+ if (!allowedActions.includes(action)) {
28
  return NextResponse.json(
29
+ { error: "Invalid action. Must be 'pause', 'resume', 'stop', or 'set-priority'" },
30
  { status: 400 }
31
  );
32
  }
 
63
  }
64
  } else if (action === "set-priority") {
65
  // Handle priority update
66
+ const { priority } = body;
67
  if (!priority || !["low", "medium", "high"].includes(priority)) {
68
  return NextResponse.json({ error: "Invalid priority" }, { status: 400 });
69
  }
app/api/tasks/route.ts CHANGED
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
2
  import { auth } from "@/lib/auth";
3
  import { db } from "@/db";
4
  import { scrapingJobs, automationWorkflows } from "@/db/schema";
5
- import { eq, desc } from "drizzle-orm";
6
 
7
  export async function GET() {
8
  try {
@@ -83,3 +83,39 @@ export async function GET() {
83
  );
84
  }
85
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  import { auth } from "@/lib/auth";
3
  import { db } from "@/db";
4
  import { scrapingJobs, automationWorkflows } from "@/db/schema";
5
+ import { eq, desc, and } from "drizzle-orm";
6
 
7
  export async function GET() {
8
  try {
 
83
  );
84
  }
85
  }
86
+
87
+ export async function DELETE(request: Request) {
88
+ try {
89
+ const session = await auth();
90
+ if (!session?.user) {
91
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
92
+ }
93
+
94
+ const { searchParams } = new URL(request.url);
95
+ const id = searchParams.get("id");
96
+ const type = searchParams.get("type");
97
+ const userId = session.user.id;
98
+
99
+ if (!id || !type) {
100
+ return NextResponse.json({ error: "ID and type required" }, { status: 400 });
101
+ }
102
+
103
+ if (type === "workflow") {
104
+ await db
105
+ .delete(automationWorkflows)
106
+ .where(and(eq(automationWorkflows.id, id), eq(automationWorkflows.userId, userId)));
107
+ } else {
108
+ await db
109
+ .delete(scrapingJobs)
110
+ .where(and(eq(scrapingJobs.id, id), eq(scrapingJobs.userId, userId)));
111
+ }
112
+
113
+ return NextResponse.json({ success: true });
114
+ } catch (error) {
115
+ console.error("Error deleting task:", error);
116
+ return NextResponse.json(
117
+ { error: "Failed to delete task" },
118
+ { status: 500 }
119
+ );
120
+ }
121
+ }
app/dashboard/businesses/page.tsx CHANGED
@@ -1,450 +1,167 @@
1
- "use client"; // Force rebuild
2
 
3
- import { useState } from "react";
4
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
5
- import { Input } from "@/components/ui/input";
 
 
6
  import { Button } from "@/components/ui/button";
7
- import { Badge } from "@/components/ui/badge";
8
- import { Label } from "@/components/ui/label";
 
 
9
  import {
10
- Search,
11
- Download,
12
- MoreVertical,
13
- Mail,
14
- Phone,
15
- Globe,
16
- MapPin,
17
- Star,
18
- Trash2,
19
- Eye,
20
- Loader2
21
- } from "lucide-react";
22
- import {
23
- DropdownMenu,
24
- DropdownMenuContent,
25
- DropdownMenuItem,
26
- DropdownMenuTrigger,
27
- } from "@/components/ui/dropdown-menu";
28
- import {
29
- Table,
30
- TableBody,
31
- TableCell,
32
- TableHead,
33
- TableHeader,
34
- TableRow,
35
- } from "@/components/ui/table";
36
- import { useBusinesses, BusinessResponse } from "@/hooks/use-businesses";
37
-
38
-
39
 
40
  export default function BusinessesPage() {
41
- const {
42
- businesses,
43
- loading,
44
- filterCategory,
45
- setFilterCategory,
46
- filterStatus,
47
- setFilterStatus,
48
- deleteBusiness,
49
- updateBusiness
50
- } = useBusinesses();
51
 
52
- const [searchQuery, setSearchQuery] = useState("");
53
  const [currentPage, setCurrentPage] = useState(1);
54
- const [editingBusiness, setEditingBusiness] = useState<BusinessResponse | null>(null);
55
- const [isEditModalOpen, setIsEditModalOpen] = useState(false);
56
- const [isSaving, setIsSaving] = useState(false);
57
- const itemsPerPage = 20;
58
-
59
- const handleDelete = async (id: string) => {
60
- await deleteBusiness(id);
61
- };
62
-
63
- const handleEdit = (business: BusinessResponse) => {
64
- setEditingBusiness(business);
65
- setIsEditModalOpen(true);
66
- };
67
-
68
- const handleSaveEdit = async (updatedBusiness: Partial<BusinessResponse>) => {
69
- if (!editingBusiness) return;
70
-
71
- setIsSaving(true);
72
- const success = await updateBusiness(editingBusiness.id, updatedBusiness);
73
- setIsSaving(false);
74
-
75
- if (success) {
76
- setIsEditModalOpen(false);
77
- setEditingBusiness(null);
 
 
 
78
  }
 
79
  };
80
 
81
- const exportToCSV = () => {
82
- const headers = ["Name", "Email", "Phone", "Website", "Address", "Category", "Rating", "Status"];
83
- const rows = filteredBusinesses.map((b) => [
84
- b.name,
85
- b.email || "",
86
- b.phone || "",
87
- b.website || "",
88
- b.address || "",
89
- b.category,
90
- b.rating || "",
91
- b.emailStatus || "pending",
92
- ]);
93
-
94
- const csv = [headers, ...rows].map((row) => row.join(",")).join("\n");
95
- const blob = new Blob([csv], { type: "text/csv" });
96
- const url = URL.createObjectURL(blob);
97
- const a = document.createElement("a");
98
- a.href = url;
99
- a.download = `businesses-${new Date().toISOString().split("T")[0]}.csv`;
100
- a.click();
101
  };
102
 
103
- const filteredBusinesses = businesses.filter((business) =>
104
- business.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
105
- business.email?.toLowerCase().includes(searchQuery.toLowerCase()) ||
106
- business.category.toLowerCase().includes(searchQuery.toLowerCase())
107
- );
108
-
109
- // Pagination
110
- const totalPages = Math.ceil(filteredBusinesses.length / itemsPerPage);
111
-
112
- const paginatedBusinesses = filteredBusinesses.slice(
113
- (currentPage - 1) * itemsPerPage,
114
- currentPage * itemsPerPage
115
- );
116
-
117
- const getStatusColor = (status: string | null) => {
118
- switch (status) {
119
- case "sent":
120
- return "default";
121
- case "opened":
122
- return "default";
123
- case "clicked":
124
- return "default";
125
- case "bounced":
126
- return "destructive";
127
- case "failed":
128
- return "destructive";
129
- default:
130
- return "secondary";
131
  }
132
  };
133
 
134
  return (
135
- <div className="space-y-6">
136
- {/* Header */}
137
- <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
138
  <div>
139
- <h1 className="text-3xl font-bold">Businesses</h1>
140
- <p className="text-muted-foreground">
141
- Manage your scraped business leads
142
- </p>
143
  </div>
144
- <Button onClick={exportToCSV} variant="outline" className="cursor-pointer w-full sm:w-auto">
145
- <Download className="mr-2 h-4 w-4" />
146
- Export CSV
147
- </Button>
 
 
148
  </div>
149
 
150
- {/* Stats */}
151
- <div className="grid gap-4 md:grid-cols-4">
152
- <Card>
153
- <CardHeader className="pb-2">
154
- <CardDescription>Total Businesses</CardDescription>
155
- <CardTitle className="text-2xl">{businesses.length}</CardTitle>
156
- </CardHeader>
157
- </Card>
158
- <Card>
159
- <CardHeader className="pb-2">
160
- <CardDescription>With Email</CardDescription>
161
- <CardTitle className="text-2xl">
162
- {businesses.filter((b) => b.email).length}
163
- </CardTitle>
164
- </CardHeader>
165
- </Card>
166
- <Card>
167
- <CardHeader className="pb-2">
168
- <CardDescription>Contacted</CardDescription>
169
- <CardTitle className="text-2xl">
170
- {businesses.filter((b) => b.emailStatus).length}
171
- </CardTitle>
172
- </CardHeader>
173
- </Card>
174
- <Card>
175
- <CardHeader className="pb-2">
176
- <CardDescription>Opened</CardDescription>
177
- <CardTitle className="text-2xl">
178
- {businesses.filter((b) => b.emailStatus === "opened" || b.emailStatus === "clicked").length}
179
- </CardTitle>
180
- </CardHeader>
181
- </Card>
182
- </div>
183
-
184
- {/* Filters */}
185
  <Card>
186
  <CardHeader>
187
- <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
188
- <div className="flex-1">
189
- <div className="relative">
190
- <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
191
- <Input
192
- placeholder="Search businesses..."
193
- value={searchQuery}
194
- onChange={(e) => setSearchQuery(e.target.value)}
195
- className="pl-10"
196
- />
197
- </div>
198
- </div>
199
- <div className="flex gap-2">
200
- <select
201
- value={filterCategory}
202
- onChange={(e) => setFilterCategory(e.target.value)}
203
- className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm cursor-pointer"
204
- >
205
- <option value="all">All Categories</option>
206
- <option value="Restaurant">Restaurant</option>
207
- <option value="Retail">Retail</option>
208
- <option value="Service">Service</option>
209
- </select>
210
- <select
211
- value={filterStatus}
212
- onChange={(e) => setFilterStatus(e.target.value)}
213
- className="flex h-10 rounded-md border border-input bg-background px-3 py-2 text-sm cursor-pointer"
214
- >
215
- <option value="all">All Status</option>
216
- <option value="pending">Pending</option>
217
- <option value="sent">Sent</option>
218
- <option value="opened">Opened</option>
219
- <option value="clicked">Clicked</option>
220
- </select>
221
- </div>
222
- </div>
223
  </CardHeader>
224
  <CardContent>
225
- {loading ? (
226
- <div className="text-center py-8 text-muted-foreground">
227
- Loading businesses...
228
- </div>
229
- ) : filteredBusinesses.length === 0 ? (
230
- <div className="text-center py-8 text-muted-foreground">
231
- No businesses found
232
- </div>
233
- ) : (
234
- <>
235
- <Table>
236
- <TableHeader>
237
- <TableRow>
238
- <TableHead>Business</TableHead>
239
- <TableHead>Contact</TableHead>
240
- <TableHead>Category</TableHead>
241
- <TableHead>Rating</TableHead>
242
- <TableHead>Status</TableHead>
243
- <TableHead className="text-right">Actions</TableHead>
244
- </TableRow>
245
- </TableHeader>
246
- <TableBody>
247
- {paginatedBusinesses.map((business) => (
248
- <TableRow key={business.id}>
249
- <TableCell>
250
- <div className="space-y-1">
251
- <div className="font-medium">{business.name}</div>
252
- {business.address && (
253
- <div className="flex items-center gap-1 text-xs text-muted-foreground">
254
- <MapPin className="h-3 w-3" />
255
- {business.address}
256
- </div>
257
- )}
258
- </div>
259
- </TableCell>
260
- <TableCell>
261
- <div className="space-y-1 text-sm">
262
- {business.email && (
263
- <div className="flex items-center gap-1">
264
- <Mail className="h-3 w-3" />
265
- {business.email}
266
- </div>
267
- )}
268
- {business.phone && (
269
- <div className="flex items-center gap-1">
270
- <Phone className="h-3 w-3" />
271
- {business.phone}
272
- </div>
273
- )}
274
- {business.website && (
275
- <div className="flex items-center gap-1">
276
- <Globe className="h-3 w-3" />
277
- <a
278
- href={business.website}
279
- target="_blank"
280
- rel="noopener noreferrer"
281
- className="text-blue-600 hover:underline cursor-pointer"
282
- >
283
- Website
284
- </a>
285
- </div>
286
- )}
287
- </div>
288
- </TableCell>
289
- <TableCell>
290
- <Badge variant="outline">{business.category}</Badge>
291
- </TableCell>
292
- <TableCell>
293
- {business.rating && (
294
- <div className="flex items-center gap-1">
295
- <Star className="h-4 w-4 fill-yellow-400 text-yellow-400" />
296
- <span className="font-medium">{business.rating}</span>
297
- <span className="text-xs text-muted-foreground">
298
- ({business.reviewCount})
299
- </span>
300
- </div>
301
- )}
302
- </TableCell>
303
- <TableCell>
304
- <Badge variant={getStatusColor(business.emailStatus)}>
305
- {business.emailStatus || "pending"}
306
- </Badge>
307
- </TableCell>
308
- <TableCell className="text-right">
309
- <DropdownMenu>
310
- <DropdownMenuTrigger asChild>
311
- <Button variant="ghost" size="icon" className="cursor-pointer">
312
- <MoreVertical className="h-4 w-4" />
313
- </Button>
314
- </DropdownMenuTrigger>
315
- <DropdownMenuContent align="end">
316
- <DropdownMenuItem
317
- className="cursor-pointer"
318
- onClick={() => handleEdit(business)}
319
- >
320
- <Eye className="mr-2 h-4 w-4" />
321
- Edit Details
322
- </DropdownMenuItem>
323
- <DropdownMenuItem className="cursor-pointer">
324
- <Mail className="mr-2 h-4 w-4" />
325
- Send Email
326
- </DropdownMenuItem>
327
- <DropdownMenuItem
328
- className="text-destructive cursor-pointer"
329
- onClick={() => handleDelete(business.id)}
330
- >
331
- <Trash2 className="mr-2 h-4 w-4" />
332
- Delete
333
- </DropdownMenuItem>
334
- </DropdownMenuContent>
335
- </DropdownMenu>
336
- </TableCell>
337
- </TableRow>
338
- ))}
339
- </TableBody>
340
- </Table>
341
-
342
- {/* Pagination Controls */}
343
- {totalPages > 1 && (
344
- <div className="flex items-center justify-between px-4 py-4 border-t">
345
- <div className="text-sm text-muted-foreground">
346
- Showing {(currentPage - 1) * itemsPerPage + 1} to {Math.min(currentPage * itemsPerPage, filteredBusinesses.length)} of {filteredBusinesses.length} businesses
347
- </div>
348
- <div className="flex gap-2">
349
- <Button
350
- variant="outline"
351
- size="sm"
352
- onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
353
- disabled={currentPage === 1}
354
- className="cursor-pointer"
355
- >
356
- Previous
357
- </Button>
358
- <div className="flex items-center gap-1">
359
- {Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
360
- <Button
361
- key={page}
362
- variant={currentPage === page ? "default" : "outline"}
363
- size="sm"
364
- onClick={() => setCurrentPage(page)}
365
- className="cursor-pointer min-w-[40px]"
366
- >
367
- {page}
368
- </Button>
369
- ))}
370
- </div>
371
- <Button
372
- variant="outline"
373
- size="sm"
374
- onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
375
- disabled={currentPage === totalPages}
376
- className="cursor-pointer"
377
- >
378
- Next
379
- </Button>
380
- </div>
381
- </div>
382
- )}
383
- </>
384
- )}
385
  </CardContent>
386
  </Card>
387
 
388
- {/* Edit Modal */}
389
- {isEditModalOpen && editingBusiness && (
390
- <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
391
- <Card className="w-full max-w-md">
392
- <CardHeader>
393
- <CardTitle>Edit Business</CardTitle>
394
- <CardDescription>Update business information</CardDescription>
395
- </CardHeader>
396
- <CardContent className="space-y-4">
397
- <div>
398
- <Label>Business Name</Label>
399
- <Input
400
- defaultValue={editingBusiness.name}
401
- onChange={(e) => setEditingBusiness({ ...editingBusiness, name: e.target.value })}
402
- />
403
- </div>
404
- <div>
405
- <Label>Email</Label>
406
- <Input
407
- defaultValue={editingBusiness.email || ""}
408
- onChange={(e) => setEditingBusiness({ ...editingBusiness, email: e.target.value })}
409
- />
410
- </div>
411
- <div>
412
- <Label>Phone</Label>
413
- <Input
414
- defaultValue={editingBusiness.phone || ""}
415
- onChange={(e) => setEditingBusiness({ ...editingBusiness, phone: e.target.value })}
416
- />
417
- </div>
418
- <div className="flex gap-2 pt-4">
419
- <Button
420
- onClick={() => handleSaveEdit(editingBusiness)}
421
- className="cursor-pointer flex-1"
422
- disabled={isSaving}
423
- >
424
- {isSaving ? (
425
- <>
426
- <Loader2 className="mr-2 h-4 w-4 animate-spin" />
427
- Saving...
428
- </>
429
- ) : (
430
- "Save Changes"
431
- )}
432
- </Button>
433
- <Button
434
- variant="outline"
435
- onClick={() => {
436
- setIsEditModalOpen(false);
437
- setEditingBusiness(null);
438
- }}
439
- className="cursor-pointer flex-1"
440
- >
441
- Cancel
442
- </Button>
443
- </div>
444
- </CardContent>
445
- </Card>
446
- </div>
447
- )}
448
  </div>
449
  );
450
  }
 
1
+ "use client";
2
 
3
+ import { useEffect, useState } from "react";
4
+ import { Business } from "@/types";
5
+ import { useApi } from "@/hooks/use-api";
6
+ import { BusinessTable } from "@/components/dashboard/business-table";
7
+ import { BusinessDetailModal } from "@/components/dashboard/business-detail-modal";
8
  import { Button } from "@/components/ui/button";
9
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
10
+ import { bulkDeleteBusinesses } from "@/app/actions/business";
11
+ import { Trash2 } from "lucide-react";
12
+ import { toast } from "sonner";
13
  import {
14
+ AlertDialog,
15
+ AlertDialogAction,
16
+ AlertDialogCancel,
17
+ AlertDialogContent,
18
+ AlertDialogDescription,
19
+ AlertDialogFooter,
20
+ AlertDialogHeader,
21
+ AlertDialogTitle,
22
+ } from "@/components/ui/alert-dialog";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  export default function BusinessesPage() {
25
+ const [businesses, setBusinesses] = useState<Business[]>([]);
26
+ const [selectedBusiness, setSelectedBusiness] = useState<Business | null>(null);
27
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
28
+ const [isModalOpen, setIsModalOpen] = useState(false);
29
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
 
 
 
 
 
30
 
31
+ // Pagination state
32
  const [currentPage, setCurrentPage] = useState(1);
33
+ const [totalPages, setTotalPages] = useState(1);
34
+ const [limit] = useState(10); // Or make this adjustable
35
+
36
+ const { get: getBusinessesApi } = useApi<{ businesses: Business[], totalPages: number, page: number }>();
37
+
38
+ useEffect(() => {
39
+ let mounted = true;
40
+ const load = async () => {
41
+ // Append query params manually or via options if useApi supported it, but template literal is fine here
42
+ const data = await getBusinessesApi(`/api/businesses?page=${currentPage}&limit=${limit}`);
43
+ if (mounted && data) {
44
+ setBusinesses(data.businesses);
45
+ setTotalPages(data.totalPages || 1);
46
+ }
47
+ };
48
+ load();
49
+ return () => { mounted = false; };
50
+ }, [getBusinessesApi, currentPage, limit]);
51
+
52
+ const handleConfirmDelete = async () => {
53
+ try {
54
+ await bulkDeleteBusinesses(selectedIds);
55
+ setBusinesses(prev => prev.filter(b => !selectedIds.includes(b.id)));
56
+ setSelectedIds([]);
57
+ toast.success("Deleted successfully");
58
+ } catch {
59
+ toast.error("Failed to delete");
60
  }
61
+ setDeleteDialogOpen(false);
62
  };
63
 
64
+ const handleViewDetails = (business: Business) => {
65
+ setSelectedBusiness(business);
66
+ setIsModalOpen(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  };
68
 
69
+ const { post: sendEmailApi } = useApi();
70
+
71
+ const handleSendEmail = async (business: Business) => {
72
+ const toastId = toast.loading(`Sending email to ${business.name}...`);
73
+ try {
74
+ await sendEmailApi("/api/email/send", { businessId: business.id });
75
+ toast.success(`Email sent to ${business.email}`, { id: toastId });
76
+
77
+ // Update local state
78
+ setBusinesses(prev => prev.map(b =>
79
+ b.id === business.id
80
+ ? { ...b, emailStatus: "sent", emailSent: true }
81
+ : b
82
+ ));
83
+ } catch (error) {
84
+ toast.error("Failed to send email", { id: toastId });
85
+ console.error(error);
 
 
 
 
 
 
 
 
 
 
 
86
  }
87
  };
88
 
89
  return (
90
+ <div className="space-y-6 pt-6">
91
+ <div className="flex justify-between items-center">
 
92
  <div>
93
+ <h2 className="text-3xl font-bold tracking-tight">Your Businesses</h2>
94
+ <p className="text-muted-foreground">Manage all your collected leads</p>
 
 
95
  </div>
96
+ {selectedIds.length > 0 && (
97
+ <Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
98
+ <Trash2 className="mr-2 h-4 w-4" />
99
+ Delete ({selectedIds.length})
100
+ </Button>
101
+ )}
102
  </div>
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  <Card>
105
  <CardHeader>
106
+ <CardTitle>All Leads ({businesses.length})</CardTitle>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  </CardHeader>
108
  <CardContent>
109
+ <BusinessTable
110
+ businesses={businesses}
111
+ onViewDetails={handleViewDetails}
112
+ onSendEmail={handleSendEmail}
113
+ selectedIds={selectedIds}
114
+ onSelectionChange={setSelectedIds}
115
+ />
116
+
117
+ {/* Pagination Controls */}
118
+ <div className="flex items-center justify-end space-x-2 py-4">
119
+ <Button
120
+ variant="outline"
121
+ size="sm"
122
+ onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
123
+ disabled={currentPage === 1}
124
+ >
125
+ Previous
126
+ </Button>
127
+ <span className="text-sm text-muted-foreground">
128
+ Page {currentPage} of {totalPages}
129
+ </span>
130
+ <Button
131
+ variant="outline"
132
+ size="sm"
133
+ onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
134
+ disabled={currentPage === totalPages}
135
+ >
136
+ Next
137
+ </Button>
138
+ </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
139
  </CardContent>
140
  </Card>
141
 
142
+ <BusinessDetailModal
143
+ business={selectedBusiness}
144
+ isOpen={isModalOpen}
145
+ onClose={() => setIsModalOpen(false)}
146
+ onSendEmail={handleSendEmail}
147
+ />
148
+
149
+ <AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
150
+ <AlertDialogContent>
151
+ <AlertDialogHeader>
152
+ <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
153
+ <AlertDialogDescription>
154
+ This action cannot be undone. This will permanently delete {selectedIds.length} businesses from your list.
155
+ </AlertDialogDescription>
156
+ </AlertDialogHeader>
157
+ <AlertDialogFooter>
158
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
159
+ <AlertDialogAction onClick={handleConfirmDelete} className="bg-red-600 hover:bg-red-700">
160
+ Delete
161
+ </AlertDialogAction>
162
+ </AlertDialogFooter>
163
+ </AlertDialogContent>
164
+ </AlertDialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  </div>
166
  );
167
  }
app/dashboard/layout.tsx CHANGED
@@ -1,12 +1,12 @@
1
  import { auth } from "@/lib/auth";
2
  import { redirect } from "next/navigation";
3
  import { Sidebar } from "@/components/dashboard/sidebar";
4
- import { NotificationToast } from "@/components/notification-toast";
5
  import { SupportPopup } from "@/components/support-popup";
6
  import { DemoNotifications } from "@/components/demo-notifications";
7
  import { SidebarProvider } from "@/components/dashboard/sidebar-provider";
8
  import { DashboardContent } from "@/components/dashboard/dashboard-content";
9
  import { FeedbackButton } from "@/components/feedback-button";
 
10
 
11
  export default async function DashboardLayout({
12
  children,
@@ -19,15 +19,26 @@ export default async function DashboardLayout({
19
  redirect("/auth/signin");
20
  }
21
 
 
 
 
 
 
 
 
 
 
22
  return (
23
  <SidebarProvider>
24
- <div className="flex h-screen overflow-hidden">
25
- <Sidebar />
26
- <DashboardContent>{children}</DashboardContent>
27
- <NotificationToast />
28
- <SupportPopup />
29
- <DemoNotifications />
30
- <FeedbackButton />
 
 
31
  </div>
32
  </SidebarProvider>
33
  );
 
1
  import { auth } from "@/lib/auth";
2
  import { redirect } from "next/navigation";
3
  import { Sidebar } from "@/components/dashboard/sidebar";
 
4
  import { SupportPopup } from "@/components/support-popup";
5
  import { DemoNotifications } from "@/components/demo-notifications";
6
  import { SidebarProvider } from "@/components/dashboard/sidebar-provider";
7
  import { DashboardContent } from "@/components/dashboard/dashboard-content";
8
  import { FeedbackButton } from "@/components/feedback-button";
9
+ import { InfoBanner } from "@/components/info-banner";
10
 
11
  export default async function DashboardLayout({
12
  children,
 
19
  redirect("/auth/signin");
20
  }
21
 
22
+ // Fetch active banner
23
+ const { db } = await import("@/db");
24
+ const { banners } = await import("@/db/schema");
25
+ const { eq } = await import("drizzle-orm");
26
+ const activeBanner = await db.query.banners.findFirst({
27
+ where: eq(banners.isActive, true),
28
+ orderBy: (banners, { desc }) => [desc(banners.createdAt)],
29
+ });
30
+
31
  return (
32
  <SidebarProvider>
33
+ <div className="flex h-screen overflow-hidden flex-col">
34
+ {activeBanner && <InfoBanner message={activeBanner.message} id={activeBanner.id} />}
35
+ <div className="flex flex-1 overflow-hidden">
36
+ <Sidebar />
37
+ <DashboardContent>{children}</DashboardContent>
38
+ <SupportPopup />
39
+ <DemoNotifications />
40
+ <FeedbackButton />
41
+ </div>
42
  </div>
43
  </SidebarProvider>
44
  );
app/dashboard/page.tsx CHANGED
@@ -21,18 +21,18 @@ import {
21
  import { BusinessTypeSelect } from "@/components/business-type-select";
22
  import { KeywordInput } from "@/components/keyword-input";
23
  import { ActiveTaskCard } from "@/components/active-task-card";
24
- import {
25
- LineChart,
26
- Line,
27
- XAxis,
28
- YAxis,
29
- CartesianGrid,
30
- Tooltip,
31
- ResponsiveContainer,
32
- } from "recharts";
33
  import { useApi } from "@/hooks/use-api";
34
  import { toast } from "sonner";
35
  import { allLocations } from "@/lib/locations";
 
36
 
37
  interface DashboardStats {
38
  totalBusinesses: number;
@@ -146,9 +146,22 @@ export default function DashboardPage() {
146
  };
147
  }, [getBusinessesApi, fetchDashboardStats, fetchActiveTask]);
148
 
 
 
149
  const handleSendEmail = async (business: Business) => {
150
- // Send email API call
151
- console.log("Sending email to:", business);
 
 
 
 
 
 
 
 
 
 
 
152
  };
153
 
154
  const handleStartScraping = async () => {
@@ -164,8 +177,12 @@ export default function DashboardPage() {
164
  });
165
 
166
  if (result) {
167
- toast.success("Scraping job started!", {
168
- description: "Check the Tasks page to monitor progress.",
 
 
 
 
169
  });
170
 
171
  // Refresh businesses list after a short delay
@@ -204,7 +221,11 @@ export default function DashboardPage() {
204
  (kw: string) => !keywords.includes(kw)
205
  );
206
  setKeywords([...keywords, ...newKeywords]);
207
- toast.success(`Generated ${newKeywords.length} keywords!`);
 
 
 
 
208
  }
209
  } catch (error) {
210
  toast.error("Failed to generate keywords");
@@ -291,11 +312,8 @@ export default function DashboardPage() {
291
  type="checkbox"
292
  checked={scrapingSources.includes("google-maps")}
293
  onChange={(e) => {
294
- if (e.target.checked) {
295
- setScrapingSources([...scrapingSources, "google-maps"]);
296
- } else {
297
- setScrapingSources(scrapingSources.filter((s: string) => s !== "google-maps"));
298
- }
299
  }}
300
  className="w-4 h-4 rounded border-gray-300"
301
  />
@@ -306,16 +324,49 @@ export default function DashboardPage() {
306
  type="checkbox"
307
  checked={scrapingSources.includes("google-search")}
308
  onChange={(e) => {
309
- if (e.target.checked) {
310
- setScrapingSources([...scrapingSources, "google-search"]);
311
- } else {
312
- setScrapingSources(scrapingSources.filter((s: string) => s !== "google-search"));
313
- }
314
  }}
315
  className="w-4 h-4 rounded border-gray-300"
316
  />
317
  <span className="text-sm">🔍 Google Search</span>
318
  </label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  </div>
320
  <p className="text-xs text-muted-foreground">
321
  Select sources to scrape from. More sources = more comprehensive results.
@@ -357,7 +408,8 @@ export default function DashboardPage() {
357
  <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
358
  {loadingStats || !stats ? (
359
  Array.from({ length: 4 }).map((_, i) => (
360
- <Card key={i}>
 
361
  <CardHeader>
362
  <div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
363
  </CardHeader>
@@ -365,76 +417,65 @@ export default function DashboardPage() {
365
  <div className="h-8 w-16 bg-muted animate-pulse rounded"></div>
366
  </CardContent>
367
  </Card>
 
368
  ))
369
  ) : (
370
  <>
 
371
  <StatCard
372
  title="Total Businesses"
373
  value={stats.totalBusinesses}
374
  icon={Users}
375
  />
 
 
376
  <StatCard
377
  title="Emails Sent"
378
  value={stats.emailsSent}
379
  icon={Mail}
380
  />
 
 
381
  <StatCard
382
  title="Emails Opened"
383
  value={stats.emailsOpened}
384
  icon={CheckCircle2}
385
  />
 
 
386
  <StatCard
387
  title="Open Rate"
388
  value={`${stats.openRate}% `}
389
  icon={TrendingUp}
390
  />
 
391
  </>
392
  )}
393
  </div>
394
 
395
  {/* Chart */}
 
396
  <Card>
397
  <CardHeader>
398
  <CardTitle>Email Performance (Last 7 Days)</CardTitle>
399
  </CardHeader>
400
- <CardContent>
401
- {loadingStats || chartData.length === 0 ? (
402
- <div className="h-[300px] flex items-center justify-center text-muted-foreground">
403
- {loadingStats ? <Loader2 className="animate-spin h-6 w-6" /> : "No data available"}
404
- </div>
405
- ) : (
406
- <ResponsiveContainer width="100%" height={300}>
407
- <LineChart data={chartData}>
408
- <CartesianGrid strokeDasharray="3 3" />
409
- <XAxis dataKey="name" />
410
- <YAxis />
411
- <Tooltip />
412
- <Line
413
- type="monotone"
414
- dataKey="sent"
415
- stroke="#3b82f6"
416
- strokeWidth={2}
417
- />
418
- <Line
419
- type="monotone"
420
- dataKey="opened"
421
- stroke="#10b981"
422
- strokeWidth={2}
423
- />
424
- </LineChart>
425
- </ResponsiveContainer>
426
- )}
427
  </CardContent>
428
  </Card>
 
429
 
430
  {/* Business Table */}
431
  <Card>
432
- <CardHeader>
433
- <CardTitle>Businesses ({businesses.length})</CardTitle>
 
 
 
434
  </CardHeader>
435
  <CardContent>
436
  <BusinessTable
437
- businesses={businesses}
438
  onViewDetails={handleViewDetails}
439
  onSendEmail={handleSendEmail}
440
  />
 
21
  import { BusinessTypeSelect } from "@/components/business-type-select";
22
  import { KeywordInput } from "@/components/keyword-input";
23
  import { ActiveTaskCard } from "@/components/active-task-card";
24
+ // Recharts moved to separate component
25
+ import dynamic from "next/dynamic";
26
+ import { AnimatedContainer } from "@/components/animated-container";
27
+
28
+ const EmailChart = dynamic(() => import("@/components/dashboard/email-chart"), {
29
+ loading: () => <div className="h-[300px] flex items-center justify-center"><Loader2 className="animate-spin h-6 w-6" /></div>,
30
+ ssr: false
31
+ });
 
32
  import { useApi } from "@/hooks/use-api";
33
  import { toast } from "sonner";
34
  import { allLocations } from "@/lib/locations";
35
+ import { sendNotification } from "@/components/notification-bell";
36
 
37
  interface DashboardStats {
38
  totalBusinesses: number;
 
146
  };
147
  }, [getBusinessesApi, fetchDashboardStats, fetchActiveTask]);
148
 
149
+ const { post: sendEmailApi } = useApi();
150
+
151
  const handleSendEmail = async (business: Business) => {
152
+ const toastId = toast.loading(`Sending email to ${business.name}...`);
153
+ try {
154
+ await sendEmailApi("/api/email/send", { businessId: business.id });
155
+ toast.success(`Email sent to ${business.email}`, { id: toastId });
156
+
157
+ setBusinesses(prev => prev.map(b =>
158
+ b.id === business.id
159
+ ? { ...b, emailStatus: "sent", emailSent: true }
160
+ : b
161
+ ));
162
+ } catch {
163
+ toast.error("Failed to send email", { id: toastId });
164
+ }
165
  };
166
 
167
  const handleStartScraping = async () => {
 
177
  });
178
 
179
  if (result) {
180
+ sendNotification({
181
+ title: "Scraping job started!",
182
+ message: "Check the Tasks page to monitor progress.",
183
+ type: "success",
184
+ link: "/dashboard/tasks",
185
+ actionLabel: "View Tasks"
186
  });
187
 
188
  // Refresh businesses list after a short delay
 
221
  (kw: string) => !keywords.includes(kw)
222
  );
223
  setKeywords([...keywords, ...newKeywords]);
224
+ sendNotification({
225
+ title: "Keywords Generated",
226
+ message: `Successfully generated ${newKeywords.length} new keywords.`,
227
+ type: "success"
228
+ });
229
  }
230
  } catch (error) {
231
  toast.error("Failed to generate keywords");
 
312
  type="checkbox"
313
  checked={scrapingSources.includes("google-maps")}
314
  onChange={(e) => {
315
+ if (e.target.checked) setScrapingSources([...scrapingSources, "google-maps"]);
316
+ else setScrapingSources(scrapingSources.filter(s => s !== "google-maps"));
 
 
 
317
  }}
318
  className="w-4 h-4 rounded border-gray-300"
319
  />
 
324
  type="checkbox"
325
  checked={scrapingSources.includes("google-search")}
326
  onChange={(e) => {
327
+ if (e.target.checked) setScrapingSources([...scrapingSources, "google-search"]);
328
+ else setScrapingSources(scrapingSources.filter(s => s !== "google-search"));
 
 
 
329
  }}
330
  className="w-4 h-4 rounded border-gray-300"
331
  />
332
  <span className="text-sm">🔍 Google Search</span>
333
  </label>
334
+ <label className="flex items-center gap-2 cursor-pointer">
335
+ <input
336
+ type="checkbox"
337
+ checked={scrapingSources.includes("linkedin")}
338
+ onChange={(e) => {
339
+ if (e.target.checked) setScrapingSources([...scrapingSources, "linkedin"]);
340
+ else setScrapingSources(scrapingSources.filter(s => s !== "linkedin"));
341
+ }}
342
+ className="w-4 h-4 rounded border-gray-300"
343
+ />
344
+ <span className="text-sm">💼 LinkedIn</span>
345
+ </label>
346
+ <label className="flex items-center gap-2 cursor-pointer">
347
+ <input
348
+ type="checkbox"
349
+ checked={scrapingSources.includes("facebook")}
350
+ onChange={(e) => {
351
+ if (e.target.checked) setScrapingSources([...scrapingSources, "facebook"]);
352
+ else setScrapingSources(scrapingSources.filter(s => s !== "facebook"));
353
+ }}
354
+ className="w-4 h-4 rounded border-gray-300"
355
+ />
356
+ <span className="text-sm">👥 Facebook</span>
357
+ </label>
358
+ <label className="flex items-center gap-2 cursor-pointer">
359
+ <input
360
+ type="checkbox"
361
+ checked={scrapingSources.includes("instagram")}
362
+ onChange={(e) => {
363
+ if (e.target.checked) setScrapingSources([...scrapingSources, "instagram"]);
364
+ else setScrapingSources(scrapingSources.filter(s => s !== "instagram"));
365
+ }}
366
+ className="w-4 h-4 rounded border-gray-300"
367
+ />
368
+ <span className="text-sm">📸 Instagram</span>
369
+ </label>
370
  </div>
371
  <p className="text-xs text-muted-foreground">
372
  Select sources to scrape from. More sources = more comprehensive results.
 
408
  <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
409
  {loadingStats || !stats ? (
410
  Array.from({ length: 4 }).map((_, i) => (
411
+ <AnimatedContainer key={i} delay={0.1 + (i * 0.1)}>
412
+ <Card>
413
  <CardHeader>
414
  <div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
415
  </CardHeader>
 
417
  <div className="h-8 w-16 bg-muted animate-pulse rounded"></div>
418
  </CardContent>
419
  </Card>
420
+ </AnimatedContainer>
421
  ))
422
  ) : (
423
  <>
424
+ <AnimatedContainer delay={0.2}>
425
  <StatCard
426
  title="Total Businesses"
427
  value={stats.totalBusinesses}
428
  icon={Users}
429
  />
430
+ </AnimatedContainer>
431
+ <AnimatedContainer delay={0.3}>
432
  <StatCard
433
  title="Emails Sent"
434
  value={stats.emailsSent}
435
  icon={Mail}
436
  />
437
+ </AnimatedContainer>
438
+ <AnimatedContainer delay={0.4}>
439
  <StatCard
440
  title="Emails Opened"
441
  value={stats.emailsOpened}
442
  icon={CheckCircle2}
443
  />
444
+ </AnimatedContainer>
445
+ <AnimatedContainer delay={0.5}>
446
  <StatCard
447
  title="Open Rate"
448
  value={`${stats.openRate}% `}
449
  icon={TrendingUp}
450
  />
451
+ </AnimatedContainer>
452
  </>
453
  )}
454
  </div>
455
 
456
  {/* Chart */}
457
+ <AnimatedContainer delay={0.6}>
458
  <Card>
459
  <CardHeader>
460
  <CardTitle>Email Performance (Last 7 Days)</CardTitle>
461
  </CardHeader>
462
+ <CardContent>
463
+ <EmailChart data={chartData} loading={loadingStats} />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  </CardContent>
465
  </Card>
466
+ </AnimatedContainer>
467
 
468
  {/* Business Table */}
469
  <Card>
470
+ <CardHeader className="flex flex-row items-center justify-between">
471
+ <CardTitle>Recent Leads ({businesses.length})</CardTitle>
472
+ <Button variant="outline" asChild>
473
+ <a href="/dashboard/businesses">View All</a>
474
+ </Button>
475
  </CardHeader>
476
  <CardContent>
477
  <BusinessTable
478
+ businesses={businesses.slice(0, 10)}
479
  onViewDetails={handleViewDetails}
480
  onSendEmail={handleSendEmail}
481
  />
app/dashboard/tasks/page.tsx CHANGED
@@ -2,18 +2,10 @@
2
 
3
  import { useState, useEffect, useCallback } from "react";
4
  import { useRouter } from "next/navigation";
5
- import { Card, CardContent } from "@/components/ui/card";
6
  import { Button } from "@/components/ui/button";
7
- import { Badge } from "@/components/ui/badge";
8
- import { Plus, Check, Clock, AlertCircle, Pause, Play, StopCircle, RefreshCw } from "lucide-react";
9
  import { useApi } from "@/hooks/use-api";
10
  import { toast } from "sonner";
11
- import {
12
- DropdownMenu,
13
- DropdownMenuContent,
14
- DropdownMenuItem,
15
- DropdownMenuTrigger,
16
- } from "@/components/ui/dropdown-menu";
17
  import {
18
  DndContext,
19
  DragOverlay,
@@ -26,34 +18,30 @@ import {
26
  DragEndEvent
27
  } from "@dnd-kit/core";
28
  import {
29
- SortableContext,
30
  sortableKeyboardCoordinates,
31
- verticalListSortingStrategy,
32
- useSortable
33
  } from "@dnd-kit/sortable";
34
- import { CSS } from "@dnd-kit/utilities";
35
-
36
- interface Task {
37
- id: string;
38
- title: string;
39
- description: string;
40
- status: "pending" | "in-progress" | "completed" | "processing" | "paused" | "failed" | "running";
41
- priority: "low" | "medium" | "high";
42
- type?: "scraping" | "workflow";
43
- businessesFound?: number;
44
- workflowName?: string;
45
- createdAt: Date;
46
- }
47
 
48
  export default function TasksPage() {
49
  const [tasks, setTasks] = useState<Task[]>([]);
50
  const [controllingTaskId, setControllingTaskId] = useState<string | null>(null);
51
  const [refreshing, setRefreshing] = useState(false);
 
 
 
 
 
 
52
  const router = useRouter();
53
 
54
  const { get: getTasks, loading } = useApi<Task[]>();
55
  const { post: controlScraping } = useApi();
56
  const { patch: updateWorkflow } = useApi();
 
57
 
58
  // Fetch tasks function
59
  const fetchTasks = useCallback(async () => {
@@ -104,10 +92,21 @@ export default function TasksPage() {
104
  }
105
  };
106
 
 
 
 
 
 
 
 
 
107
  const handlePriorityChange = async (taskId: string, priority: "low" | "medium" | "high") => {
108
  const task = tasks.find(t => t.id === taskId);
109
  if (!task) return;
110
 
 
 
 
111
  try {
112
  let result;
113
 
@@ -125,40 +124,53 @@ export default function TasksPage() {
125
 
126
  if (result) {
127
  toast.success(`Priority updated to ${priority}`);
128
- await fetchTasks();
 
129
  }
130
  } catch (error) {
131
  toast.error("Failed to update priority");
132
  console.error("Error updating priority:", error);
 
 
133
  }
134
  };
135
 
136
- // Removed local storage logic and manual task creation
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
 
138
  const pendingTasks = tasks.filter((t) => t.status === "pending");
139
  const inProgressTasks = tasks.filter((t) =>
140
  t.status === "in-progress" ||
141
  t.status === "processing" ||
142
- t.status === "paused"
 
143
  );
144
  const completedTasks = tasks.filter((t) =>
145
  t.status === "completed" ||
146
  t.status === "failed"
147
  );
148
 
149
- const priorityColors = {
150
- low: "bg-blue-500",
151
- medium: "bg-yellow-500",
152
- high: "bg-red-500",
153
- };
154
-
155
  /* Drag and Drop State */
156
- const [activeTask, setActiveTask] = useState<Task | null>(null);
157
-
158
  const sensors = useSensors(
159
  useSensor(PointerSensor, {
160
  activationConstraint: {
161
- distance: 8, // Requires 8px movement to start drag, allowing button clicks
162
  },
163
  }),
164
  useSensor(KeyboardSensor, {
@@ -184,7 +196,7 @@ export default function TasksPage() {
184
  const overId = over.id as string;
185
  const activeTask = tasks.find((t) => t.id === activeId);
186
 
187
- // Determine target column based on overId (which could be a task ID or column ID)
188
  let targetStatus: Task["status"] | null = null;
189
 
190
  if (overId === "pending-column" || overId === "in-progress-column" || overId === "completed-column") {
@@ -195,9 +207,8 @@ export default function TasksPage() {
195
  // Dropped over another task
196
  const overTask = tasks.find((t) => t.id === overId);
197
  if (overTask) {
198
- // Map task status to column group
199
  if (overTask.status === "processing" || overTask.status === "paused" || overTask.status === "running") {
200
- targetStatus = "in-progress"; // Group these under in-progress
201
  } else {
202
  targetStatus = overTask.status;
203
  }
@@ -228,33 +239,35 @@ export default function TasksPage() {
228
  };
229
 
230
  return (
231
- <div className="p-6 space-y-6">
232
- <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
233
  <div>
234
- <h1 className="text-3xl font-bold tracking-tight">Task Management</h1>
235
- <p className="text-muted-foreground">
236
- Track your automated scraping and outreach jobs
237
  </p>
238
  </div>
239
- <div className="flex gap-2">
240
  <Button
241
  onClick={fetchTasks}
242
  variant="outline"
243
  disabled={refreshing || loading}
244
- className="w-full sm:w-auto"
245
  >
246
  <RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
247
  Refresh
248
  </Button>
249
- <Button onClick={() => router.push("/dashboard/workflows?create=true")} className="w-full sm:w-auto">
250
  <Plus className="mr-2 h-4 w-4" />
251
  New Automation
252
  </Button>
253
  </div>
254
  </div>
255
 
256
- {loading ? (
257
- <div className="flex justify-center p-12">Loading tasks...</div>
 
 
258
  ) : (
259
  <DndContext
260
  sensors={sensors}
@@ -262,63 +275,52 @@ export default function TasksPage() {
262
  onDragStart={handleDragStart}
263
  onDragEnd={handleDragEnd}
264
  >
265
- <div className="grid gap-6 md:grid-cols-3">
266
  {/* Pending Column */}
267
- <SortableContext
268
  id="pending-column"
269
- items={pendingTasks.map(t => t.id)}
270
- strategy={verticalListSortingStrategy}
271
- >
272
- <TaskColumn
273
- id="pending-column"
274
- title="Pending"
275
- icon={<Clock className="h-5 w-5" />}
276
- tasks={pendingTasks}
277
- count={pendingTasks.length}
278
- priorityColors={priorityColors}
279
- />
280
- </SortableContext>
281
 
282
  {/* In Progress Column */}
283
- <SortableContext
284
  id="in-progress-column"
285
- items={inProgressTasks.map(t => t.id)}
286
- strategy={verticalListSortingStrategy}
287
- >
288
- <TaskColumn
289
- id="in-progress-column"
290
- title="In Progress"
291
- icon={<AlertCircle className="h-5 w-5" />}
292
- tasks={inProgressTasks}
293
- count={inProgressTasks.length}
294
- priorityColors={priorityColors}
295
- onControl={handleControl}
296
- controllingTaskId={controllingTaskId}
297
- />
298
- </SortableContext>
299
 
300
  {/* Completed Column */}
301
- <SortableContext
302
  id="completed-column"
303
- items={completedTasks.map(t => t.id)}
304
- strategy={verticalListSortingStrategy}
305
- >
306
- <TaskColumn
307
- id="completed-column"
308
- title="Completed"
309
- icon={<Check className="h-5 w-5" />}
310
- tasks={completedTasks}
311
- count={completedTasks.length}
312
- priorityColors={priorityColors}
313
- />
314
- </SortableContext>
315
  </div>
 
316
  <DragOverlay>
317
  {activeTask ? (
318
  <TaskCard
319
  task={activeTask}
320
- priorityColors={priorityColors}
321
- // Hide controls during drag for cleaner look, or keep them
322
  onControl={handleControl}
323
  onPriorityChange={handlePriorityChange}
324
  controllingTaskId={controllingTaskId}
@@ -327,187 +329,29 @@ export default function TasksPage() {
327
  </DragOverlay>
328
  </DndContext>
329
  )}
330
- </div>
331
- );
332
- }
333
-
334
- // Separate Column Component to handle dropping
335
- interface TaskColumnProps {
336
- id: string;
337
- title: string;
338
- icon: React.ReactNode;
339
- tasks: Task[];
340
- count: number;
341
- priorityColors: Record<string, string>;
342
- onControl?: (jobId: string, action: "pause" | "resume" | "stop") => void;
343
- onPriorityChange?: (taskId: string, priority: "low" | "medium" | "high") => void;
344
- controllingTaskId?: string | null;
345
- }
346
-
347
- function TaskColumn({ id, title, icon, tasks, count, priorityColors, onControl, onPriorityChange, controllingTaskId }: TaskColumnProps) {
348
- const { setNodeRef } = useSortable({ id });
349
 
350
- return (
351
- <div ref={setNodeRef} className="space-y-4 bg-muted/50 p-4 rounded-lg min-h-[500px]">
352
- <h3 className="font-semibold text-lg flex items-center gap-2">
353
- {icon}
354
- {title} ({count})
355
- </h3>
356
- <div className="space-y-3">
357
- {tasks.map((task: Task) => (
358
- <SortableTaskCard
359
- key={task.id}
360
- task={task}
361
- priorityColors={priorityColors}
362
- onControl={onControl}
363
- onPriorityChange={onPriorityChange}
364
- controllingTaskId={controllingTaskId}
365
- />
366
- ))}
367
- {tasks.length === 0 && (
368
- <p className="text-sm text-muted-foreground text-center py-8">
369
- No tasks
370
- </p>
371
- )}
372
- </div>
373
  </div>
374
  );
375
  }
376
-
377
- interface SortableTaskCardProps {
378
- task: Task;
379
- priorityColors: Record<string, string>;
380
- onControl?: (jobId: string, action: "pause" | "resume" | "stop") => void;
381
- onPriorityChange?: (taskId: string, priority: "low" | "medium" | "high") => void;
382
- controllingTaskId?: string | null;
383
- }
384
-
385
- function SortableTaskCard(props: SortableTaskCardProps) {
386
- const {
387
- attributes,
388
- listeners,
389
- setNodeRef,
390
- transform,
391
- transition,
392
- } = useSortable({ id: props.task.id });
393
-
394
- const style = {
395
- transform: CSS.Transform.toString(transform),
396
- transition,
397
- };
398
-
399
- return (
400
- <div ref={setNodeRef} style={style} {...attributes} {...listeners}>
401
- <TaskCard {...props} />
402
- </div>
403
- );
404
- }
405
-
406
- function TaskCard({
407
- task,
408
- priorityColors,
409
- onControl,
410
- onPriorityChange,
411
- controllingTaskId,
412
- }: {
413
- task: Task;
414
- priorityColors: Record<string, string>;
415
- onControl?: (jobId: string, action: "pause" | "resume" | "stop") => void;
416
- onPriorityChange?: (taskId: string, priority: "low" | "medium" | "high") => void;
417
- controllingTaskId?: string | null;
418
- }) {
419
- const showControls = ["running", "processing", "paused", "pending", "in-progress"].includes(task.status);
420
- const isControlling = controllingTaskId === task.id;
421
-
422
- return (
423
- <Card className="hover:shadow-md transition-shadow">
424
- <CardContent className="p-4 space-y-3">
425
- <div className="flex items-start justify-between">
426
- <div className="flex-1">
427
- <h4 className="font-medium flex items-center gap-2">
428
- {task.title}
429
- {task.type === "workflow" && (
430
- <Badge variant="secondary" className="text-[10px] h-5">Workflow</Badge>
431
- )}
432
- </h4>
433
- {task.description && (
434
- <p className="text-sm text-muted-foreground mt-1">
435
- {task.description}
436
- </p>
437
- )}
438
- </div>
439
- </div>
440
-
441
- <div className="flex items-center gap-2 flex-wrap">
442
- <DropdownMenu>
443
- <DropdownMenuTrigger asChild>
444
- <Badge variant="outline" className="text-xs cursor-pointer hover:bg-accent transition-colors">
445
- <div className={`h-2 w-2 rounded-full ${priorityColors[task.priority]} mr-1`} />
446
- {task.priority || "medium"}
447
- </Badge>
448
- </DropdownMenuTrigger>
449
- <DropdownMenuContent align="start">
450
- <DropdownMenuItem onClick={() => onPriorityChange?.(task.id, "low")}>
451
- <div className={`h-2 w-2 rounded-full ${priorityColors.low} mr-2`} />
452
- Low
453
- </DropdownMenuItem>
454
- <DropdownMenuItem onClick={() => onPriorityChange?.(task.id, "medium")}>
455
- <div className={`h-2 w-2 rounded-full ${priorityColors.medium} mr-2`} />
456
- Medium
457
- </DropdownMenuItem>
458
- <DropdownMenuItem onClick={() => onPriorityChange?.(task.id, "high")}>
459
- <div className={`h-2 w-2 rounded-full ${priorityColors.high} mr-2`} />
460
- High
461
- </DropdownMenuItem>
462
- </DropdownMenuContent>
463
- </DropdownMenu>
464
-
465
- <div className="text-xs text-muted-foreground">
466
- {new Date(task.createdAt).toLocaleDateString()}
467
- </div>
468
-
469
- {showControls && onControl && (
470
- <div className="ml-auto flex gap-2">
471
- {(task.status === "in-progress" || task.status === "processing" || task.status === "running") && (
472
- <Button
473
- size="sm"
474
- variant="outline"
475
- onClick={() => onControl(task.id, "pause")}
476
- disabled={isControlling}
477
- className="h-7 w-7 p-0"
478
- >
479
- <Pause className="h-4 w-4" />
480
- </Button>
481
- )}
482
-
483
- {task.status === "paused" && (
484
- <Button
485
- size="sm"
486
- variant="outline"
487
- onClick={() => onControl(task.id, "resume")}
488
- disabled={isControlling}
489
- className="h-7 w-7 p-0"
490
- >
491
- <Play className="h-4 w-4" />
492
- </Button>
493
- )}
494
-
495
- {/* Stop Button */}
496
- <Button
497
- size="sm"
498
- variant="outline"
499
- onClick={() => onControl(task.id, "stop")}
500
- disabled={isControlling}
501
- className="text-red-500 hover:text-red-600 h-7 w-7 p-0"
502
- >
503
- <StopCircle className="h-4 w-4" />
504
- </Button>
505
-
506
-
507
- </div>
508
- )}
509
- </div>
510
- </CardContent>
511
- </Card>
512
- );
513
- }
 
2
 
3
  import { useState, useEffect, useCallback } from "react";
4
  import { useRouter } from "next/navigation";
 
5
  import { Button } from "@/components/ui/button";
6
+ import { Plus, Clock, AlertCircle, Check, RefreshCw } from "lucide-react";
 
7
  import { useApi } from "@/hooks/use-api";
8
  import { toast } from "sonner";
 
 
 
 
 
 
9
  import {
10
  DndContext,
11
  DragOverlay,
 
18
  DragEndEvent
19
  } from "@dnd-kit/core";
20
  import {
 
21
  sortableKeyboardCoordinates,
 
 
22
  } from "@dnd-kit/sortable";
23
+
24
+ import { ConfirmDialog } from "@/components/common/confirm-dialog";
25
+ import { TaskColumn } from "@/components/dashboard/tasks/task-column";
26
+ import { TaskCard } from "@/components/dashboard/tasks/task-card";
27
+ import { Task } from "@/types";
 
 
 
 
 
 
 
 
28
 
29
  export default function TasksPage() {
30
  const [tasks, setTasks] = useState<Task[]>([]);
31
  const [controllingTaskId, setControllingTaskId] = useState<string | null>(null);
32
  const [refreshing, setRefreshing] = useState(false);
33
+ const [confirmStopId, setConfirmStopId] = useState<string | null>(null);
34
+ const [confirmDeleteId, setConfirmDeleteId] = useState<{ id: string, type: "scraping" | "workflow" } | null>(null);
35
+
36
+ // Drag state
37
+ const [activeTask, setActiveTask] = useState<Task | null>(null);
38
+
39
  const router = useRouter();
40
 
41
  const { get: getTasks, loading } = useApi<Task[]>();
42
  const { post: controlScraping } = useApi();
43
  const { patch: updateWorkflow } = useApi();
44
+ const { del: deleteTask } = useApi();
45
 
46
  // Fetch tasks function
47
  const fetchTasks = useCallback(async () => {
 
92
  }
93
  };
94
 
95
+ const handleControlRequest = (taskId: string, action: "pause" | "resume" | "stop") => {
96
+ if (action === "stop") {
97
+ setConfirmStopId(taskId);
98
+ } else {
99
+ handleControl(taskId, action);
100
+ }
101
+ };
102
+
103
  const handlePriorityChange = async (taskId: string, priority: "low" | "medium" | "high") => {
104
  const task = tasks.find(t => t.id === taskId);
105
  if (!task) return;
106
 
107
+ // Optimistic UI update
108
+ setTasks(prev => prev.map(t => t.id === taskId ? { ...t, priority } : t));
109
+
110
  try {
111
  let result;
112
 
 
124
 
125
  if (result) {
126
  toast.success(`Priority updated to ${priority}`);
127
+ // No need to fetch if optimistic update matches, but safe to fetch
128
+ // await fetchTasks();
129
  }
130
  } catch (error) {
131
  toast.error("Failed to update priority");
132
  console.error("Error updating priority:", error);
133
+ // Revert optimistic update
134
+ await fetchTasks();
135
  }
136
  };
137
 
138
+ const handleDeleteRequest = (taskId: string, type: "scraping" | "workflow" = "scraping") => {
139
+ setConfirmDeleteId({ id: taskId, type });
140
+ };
141
+
142
+ const handleDelete = async () => {
143
+ if (!confirmDeleteId) return;
144
+
145
+ try {
146
+ await deleteTask(`/api/tasks?id=${confirmDeleteId.id}&type=${confirmDeleteId.type}`);
147
+ toast.success("Task deleted successfully");
148
+ await fetchTasks();
149
+ } catch (error) {
150
+ toast.error("Failed to delete task");
151
+ console.error("Error deleting task:", error);
152
+ } finally {
153
+ setConfirmDeleteId(null);
154
+ }
155
+ };
156
 
157
  const pendingTasks = tasks.filter((t) => t.status === "pending");
158
  const inProgressTasks = tasks.filter((t) =>
159
  t.status === "in-progress" ||
160
  t.status === "processing" ||
161
+ t.status === "paused" ||
162
+ t.status === "running"
163
  );
164
  const completedTasks = tasks.filter((t) =>
165
  t.status === "completed" ||
166
  t.status === "failed"
167
  );
168
 
 
 
 
 
 
 
169
  /* Drag and Drop State */
 
 
170
  const sensors = useSensors(
171
  useSensor(PointerSensor, {
172
  activationConstraint: {
173
+ distance: 8,
174
  },
175
  }),
176
  useSensor(KeyboardSensor, {
 
196
  const overId = over.id as string;
197
  const activeTask = tasks.find((t) => t.id === activeId);
198
 
199
+ // Determine target column based on overId
200
  let targetStatus: Task["status"] | null = null;
201
 
202
  if (overId === "pending-column" || overId === "in-progress-column" || overId === "completed-column") {
 
207
  // Dropped over another task
208
  const overTask = tasks.find((t) => t.id === overId);
209
  if (overTask) {
 
210
  if (overTask.status === "processing" || overTask.status === "paused" || overTask.status === "running") {
211
+ targetStatus = "in-progress";
212
  } else {
213
  targetStatus = overTask.status;
214
  }
 
239
  };
240
 
241
  return (
242
+ <div className="p-6 space-y-8 max-w-[1600px] mx-auto">
243
+ <div className="flex flex-col gap-6 sm:flex-row sm:items-center sm:justify-between">
244
  <div>
245
+ <h1 className="text-3xl font-bold tracking-tight bg-linear-to-r from-primary to-primary/60 bg-clip-text text-transparent">Task Management</h1>
246
+ <p className="text-muted-foreground mt-1">
247
+ Track and manage your automated scraping and outreach jobs
248
  </p>
249
  </div>
250
+ <div className="flex gap-3">
251
  <Button
252
  onClick={fetchTasks}
253
  variant="outline"
254
  disabled={refreshing || loading}
255
+ className="w-full sm:w-auto shadow-sm"
256
  >
257
  <RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
258
  Refresh
259
  </Button>
260
+ <Button onClick={() => router.push("/dashboard/workflows?create=true")} className="w-full sm:w-auto shadow-md hover:shadow-lg transition-all">
261
  <Plus className="mr-2 h-4 w-4" />
262
  New Automation
263
  </Button>
264
  </div>
265
  </div>
266
 
267
+ {loading && tasks.length === 0 ? (
268
+ <div className="flex justify-center p-12">
269
+ <RefreshCw className="h-8 w-8 animate-spin text-primary" />
270
+ </div>
271
  ) : (
272
  <DndContext
273
  sensors={sensors}
 
275
  onDragStart={handleDragStart}
276
  onDragEnd={handleDragEnd}
277
  >
278
+ <div className="grid gap-6 md:grid-cols-3 h-full">
279
  {/* Pending Column */}
280
+ <TaskColumn
281
  id="pending-column"
282
+ title="Pending"
283
+ icon={<Clock className="h-5 w-5 text-pending-foreground" />}
284
+ tasks={pendingTasks}
285
+ count={pendingTasks.length}
286
+ onControl={handleControlRequest}
287
+ onPriorityChange={handlePriorityChange}
288
+ onDelete={handleDeleteRequest}
289
+ controllingTaskId={controllingTaskId}
290
+ />
 
 
 
291
 
292
  {/* In Progress Column */}
293
+ <TaskColumn
294
  id="in-progress-column"
295
+ title="In Progress"
296
+ icon={<AlertCircle className="h-5 w-5 text-blue-500" />}
297
+ tasks={inProgressTasks}
298
+ count={inProgressTasks.length}
299
+ onControl={handleControlRequest}
300
+ onPriorityChange={handlePriorityChange}
301
+ onDelete={handleDeleteRequest}
302
+ controllingTaskId={controllingTaskId}
303
+ contentClassName="max-h-[400px] overflow-y-auto pr-2 custom-scrollbar"
304
+ />
 
 
 
 
305
 
306
  {/* Completed Column */}
307
+ <TaskColumn
308
  id="completed-column"
309
+ title="Completed"
310
+ icon={<Check className="h-5 w-5 text-green-500" />}
311
+ tasks={completedTasks}
312
+ count={completedTasks.length}
313
+ onControl={handleControlRequest}
314
+ onPriorityChange={handlePriorityChange}
315
+ onDelete={handleDeleteRequest}
316
+ controllingTaskId={controllingTaskId}
317
+ />
 
 
 
318
  </div>
319
+
320
  <DragOverlay>
321
  {activeTask ? (
322
  <TaskCard
323
  task={activeTask}
 
 
324
  onControl={handleControl}
325
  onPriorityChange={handlePriorityChange}
326
  controllingTaskId={controllingTaskId}
 
329
  </DragOverlay>
330
  </DndContext>
331
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
 
333
+ <ConfirmDialog
334
+ open={!!confirmStopId}
335
+ onOpenChange={(open) => !open && setConfirmStopId(null)}
336
+ title="Stop Task?"
337
+ description="Are you sure you want to stop this task? This action cannot be undone and the task will be marked as failed/stopped."
338
+ confirmText="Stop Task"
339
+ onConfirm={() => {
340
+ if (confirmStopId) handleControl(confirmStopId, "stop");
341
+ setConfirmStopId(null);
342
+ }}
343
+ variant="destructive"
344
+ />
345
+
346
+ <ConfirmDialog
347
+ open={!!confirmDeleteId}
348
+ onOpenChange={(open) => !open && setConfirmDeleteId(null)}
349
+ title="Delete Task?"
350
+ description="Are you sure you want to delete this task? This action cannot be undone."
351
+ confirmText="Delete"
352
+ onConfirm={handleDelete}
353
+ variant="destructive"
354
+ />
 
355
  </div>
356
  );
357
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/layout.tsx CHANGED
@@ -3,6 +3,7 @@ import { Inter } from "next/font/google";
3
  import "./globals.css";
4
  import "./cursor-styles.css";
5
  import { Providers } from "./providers";
 
6
 
7
  const inter = Inter({
8
  subsets: ["latin"],
@@ -60,7 +61,10 @@ export default function RootLayout({
60
  return (
61
  <html lang="en" suppressHydrationWarning>
62
  <body className={inter.variable}>
63
- <Providers>{children}</Providers>
 
 
 
64
  </body>
65
  </html>
66
  );
 
3
  import "./globals.css";
4
  import "./cursor-styles.css";
5
  import { Providers } from "./providers";
6
+ import { Toaster } from "@/components/ui/sonner";
7
 
8
  const inter = Inter({
9
  subsets: ["latin"],
 
61
  return (
62
  <html lang="en" suppressHydrationWarning>
63
  <body className={inter.variable}>
64
+ <Providers>
65
+ {children}
66
+ <Toaster />
67
+ </Providers>
68
  </body>
69
  </html>
70
  );
app/providers.tsx CHANGED
@@ -2,14 +2,13 @@
2
 
3
  import { SessionProvider } from "next-auth/react";
4
  import { ThemeProvider } from "@/components/theme-provider";
5
- import { Toaster } from "sonner";
6
 
7
  export function Providers({ children }: { children: React.ReactNode }) {
8
  return (
9
  <SessionProvider>
10
  <ThemeProvider>
11
  {children}
12
- <Toaster richColors position="bottom-right" closeButton theme="system" />
13
  </ThemeProvider>
14
  </SessionProvider>
15
  );
 
2
 
3
  import { SessionProvider } from "next-auth/react";
4
  import { ThemeProvider } from "@/components/theme-provider";
5
+
6
 
7
  export function Providers({ children }: { children: React.ReactNode }) {
8
  return (
9
  <SessionProvider>
10
  <ThemeProvider>
11
  {children}
 
12
  </ThemeProvider>
13
  </SessionProvider>
14
  );
components/admin/admin-analytics.tsx ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import {
6
+ LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
7
+ PieChart, Pie, Cell, BarChart, Bar, Legend
8
+ } from "recharts";
9
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
10
+ import { Button } from "@/components/ui/button";
11
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
12
+ import { RefreshCcw } from "lucide-react";
13
+
14
+ interface AdminAnalyticsProps {
15
+ userGrowth: { date: string; count: number }[];
16
+ businessCategories: { name: string; value: number }[];
17
+ businessStatus: { name: string; value: number }[];
18
+ }
19
+
20
+ const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8'];
21
+
22
+ export function AdminAnalytics({ userGrowth, businessCategories, businessStatus }: AdminAnalyticsProps) {
23
+ const router = useRouter();
24
+ const [isRefreshing, setIsRefreshing] = useState(false);
25
+
26
+ const handleRefresh = () => {
27
+ setIsRefreshing(true);
28
+ router.refresh();
29
+ setTimeout(() => setIsRefreshing(false), 1000);
30
+ };
31
+
32
+ return (
33
+ <div className="space-y-4">
34
+ <div className="flex items-center justify-between">
35
+ <h2 className="text-xl font-semibold">Analytics & Activity</h2>
36
+ <div className="flex gap-2">
37
+ <Button variant="outline" size="sm" onClick={handleRefresh} disabled={isRefreshing}>
38
+ <RefreshCcw className={`mr-2 h-4 w-4 ${isRefreshing ? "animate-spin" : ""}`} />
39
+ Refresh Data
40
+ </Button>
41
+ </div>
42
+ </div>
43
+
44
+ <Tabs defaultValue="growth" className="space-y-4">
45
+ <TabsList>
46
+ <TabsTrigger value="growth">User Growth</TabsTrigger>
47
+ <TabsTrigger value="demographics">Demographics</TabsTrigger>
48
+ <TabsTrigger value="status">Lead Status</TabsTrigger>
49
+ </TabsList>
50
+
51
+ <TabsContent value="growth">
52
+ <Card>
53
+ <CardHeader>
54
+ <CardTitle>Active Graph (User Signups)</CardTitle>
55
+ <CardDescription>New user registrations over time</CardDescription>
56
+ </CardHeader>
57
+ <CardContent>
58
+ <div className="h-[300px] w-full">
59
+ <ResponsiveContainer width="100%" height="100%">
60
+ <LineChart data={userGrowth}>
61
+ <CartesianGrid strokeDasharray="3 3" />
62
+ <XAxis dataKey="date" />
63
+ <YAxis allowDecimals={false} />
64
+ <Tooltip />
65
+ <Line type="monotone" dataKey="count" stroke="#8884d8" strokeWidth={2} activeDot={{ r: 8 }} />
66
+ </LineChart>
67
+ </ResponsiveContainer>
68
+ </div>
69
+ </CardContent>
70
+ </Card>
71
+ </TabsContent>
72
+
73
+ <TabsContent value="demographics">
74
+ <Card>
75
+ <CardHeader>
76
+ <CardTitle>Business Categories</CardTitle>
77
+ <CardDescription>Distribution of scraped businesses by category</CardDescription>
78
+ </CardHeader>
79
+ <CardContent>
80
+ <div className="h-[300px] w-full">
81
+ <ResponsiveContainer width="100%" height="100%">
82
+ <PieChart>
83
+ <Pie
84
+ data={businessCategories}
85
+ cx="50%"
86
+ cy="50%"
87
+ labelLine={false}
88
+ label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
89
+ outerRadius={100}
90
+ fill="#8884d8"
91
+ dataKey="value"
92
+ >
93
+ {businessCategories.map((entry, index) => (
94
+ <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
95
+ ))}
96
+ </Pie>
97
+ <Tooltip />
98
+ </PieChart>
99
+ </ResponsiveContainer>
100
+ </div>
101
+ </CardContent>
102
+ </Card>
103
+ </TabsContent>
104
+
105
+ <TabsContent value="status">
106
+ <Card>
107
+ <CardHeader>
108
+ <CardTitle>Business Status</CardTitle>
109
+ <CardDescription>Status breakdown of current leads</CardDescription>
110
+ </CardHeader>
111
+ <CardContent>
112
+ <div className="h-[300px] w-full">
113
+ <ResponsiveContainer width="100%" height="100%">
114
+ <BarChart data={businessStatus}>
115
+ <CartesianGrid strokeDasharray="3 3" />
116
+ <XAxis dataKey="name" />
117
+ <YAxis />
118
+ <Tooltip />
119
+ <Legend />
120
+ <Bar dataKey="value" fill="#82ca9d" name="Count" />
121
+ </BarChart>
122
+ </ResponsiveContainer>
123
+ </div>
124
+ </CardContent>
125
+ </Card>
126
+ </TabsContent>
127
+ </Tabs>
128
+ </div>
129
+ );
130
+ }
components/admin/admin-business-view.tsx ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Business } from "@/types"; // Ensure Business type is compatible or inferred
5
+ import { BusinessTable } from "@/components/dashboard/business-table";
6
+ import { BusinessDetailModal } from "@/components/dashboard/business-detail-modal";
7
+ import { Button } from "@/components/ui/button";
8
+ import { adminBulkDeleteBusinesses } from "@/app/admin/actions/business";
9
+ import { Trash2 } from "lucide-react";
10
+ import { toast } from "sonner";
11
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
12
+
13
+ interface AdminBusinessViewProps {
14
+ initialBusinesses: Business[];
15
+ }
16
+
17
+ export function AdminBusinessView({ initialBusinesses }: AdminBusinessViewProps) {
18
+ const [businesses, setBusinesses] = useState<Business[]>(initialBusinesses);
19
+ const [selectedIds, setSelectedIds] = useState<string[]>([]);
20
+ const [selectedBusiness, setSelectedBusiness] = useState<Business | null>(null);
21
+ const [isModalOpen, setIsModalOpen] = useState(false);
22
+
23
+ const handleDeleteSelected = async () => {
24
+ if (!confirm(`Delete ${selectedIds.length} businesses?`)) return;
25
+
26
+ try {
27
+ await adminBulkDeleteBusinesses(selectedIds);
28
+ setBusinesses(prev => prev.filter(b => !selectedIds.includes(b.id)));
29
+ setSelectedIds([]);
30
+ toast.success("Deleted successfully");
31
+ } catch (error) {
32
+ toast.error("Failed to delete");
33
+ }
34
+ };
35
+
36
+ const handleViewDetails = (business: Business) => {
37
+ setSelectedBusiness(business);
38
+ setIsModalOpen(true);
39
+ };
40
+
41
+ return (
42
+ <div className="space-y-6">
43
+ <div className="flex justify-between items-center">
44
+ <div>
45
+ <h2 className="text-3xl font-bold tracking-tight">Businesses</h2>
46
+ <p className="text-muted-foreground">Manage scraped leads and business data</p>
47
+ </div>
48
+ {selectedIds.length > 0 && (
49
+ <Button variant="destructive" onClick={handleDeleteSelected}>
50
+ <Trash2 className="mr-2 h-4 w-4" />
51
+ Delete ({selectedIds.length})
52
+ </Button>
53
+ )}
54
+ </div>
55
+
56
+ <Card>
57
+ <CardHeader>
58
+ <CardTitle>All Leads ({businesses.length})</CardTitle>
59
+ </CardHeader>
60
+ <CardContent>
61
+ <BusinessTable
62
+ businesses={businesses}
63
+ onViewDetails={handleViewDetails}
64
+ onSendEmail={() => {}} // Admin doesn't send emails from here typically
65
+ selectedIds={selectedIds}
66
+ onSelectionChange={setSelectedIds}
67
+ />
68
+ </CardContent>
69
+ </Card>
70
+
71
+ <BusinessDetailModal
72
+ business={selectedBusiness}
73
+ isOpen={isModalOpen}
74
+ onClose={() => setIsModalOpen(false)}
75
+ />
76
+ </div>
77
+ );
78
+ }
components/admin/feedback-actions.tsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Check, Loader2 } from "lucide-react";
6
+ import { toast } from "sonner";
7
+ import { useRouter } from "next/navigation";
8
+
9
+ interface FeedbackActionsProps {
10
+ id: string;
11
+ status: string;
12
+ }
13
+
14
+ export function FeedbackActions({ id, status }: FeedbackActionsProps) {
15
+ const [loading, setLoading] = useState(false);
16
+ const router = useRouter();
17
+
18
+ if (status === "resolved") return null;
19
+
20
+ const handleResolve = async () => {
21
+ setLoading(true);
22
+ try {
23
+ const res = await fetch("/api/feedback", {
24
+ method: "PATCH",
25
+ headers: { "Content-Type": "application/json" },
26
+ body: JSON.stringify({ id, status: "resolved" }),
27
+ });
28
+
29
+ if (!res.ok) throw new Error("Failed");
30
+
31
+ toast.success("Marked as resolved");
32
+ router.refresh();
33
+ } catch {
34
+ toast.error("Failed to update status");
35
+ } finally {
36
+ setLoading(false);
37
+ }
38
+ };
39
+
40
+ return (
41
+ <Button
42
+ size="sm"
43
+ variant="outline"
44
+ onClick={handleResolve}
45
+ disabled={loading}
46
+ className="h-8"
47
+ >
48
+ {loading ? (
49
+ <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
50
+ ) : (
51
+ <Check className="h-3.5 w-3.5 mr-1" />
52
+ )}
53
+ Resolve
54
+ </Button>
55
+ );
56
+ }
components/admin/quick-actions.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import Link from "next/link";
2
+ import { Bell, Megaphone } from "lucide-react";
3
+ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
4
+ import { Button } from "@/components/ui/button";
5
+
6
+ export function QuickActions() {
7
+ return (
8
+ <Card>
9
+ <CardHeader>
10
+ <CardTitle>Quick Actions</CardTitle>
11
+ <CardDescription>Common administrative tasks</CardDescription>
12
+ </CardHeader>
13
+ <CardContent className="grid gap-4 md:grid-cols-2">
14
+ <Button variant="outline" className="h-auto py-4 flex flex-col gap-2 items-center justify-center hover:bg-muted/50" asChild>
15
+ <Link href="/admin/settings?tab=notifications">
16
+ <Bell className="h-6 w-6 text-primary" />
17
+ <span className="font-semibold">Send Notification</span>
18
+ <span className="text-xs text-muted-foreground font-normal">Broadcast to all users</span>
19
+ </Link>
20
+ </Button>
21
+
22
+ <Button variant="outline" className="h-auto py-4 flex flex-col gap-2 items-center justify-center hover:bg-muted/50" asChild>
23
+ <Link href="/admin/settings?tab=banners">
24
+ <Megaphone className="h-6 w-6 text-blue-500" />
25
+ <span className="font-semibold">Create Banner</span>
26
+ <span className="text-xs text-muted-foreground font-normal">Set info marquee</span>
27
+ </Link>
28
+ </Button>
29
+ </CardContent>
30
+ </Card>
31
+ );
32
+ }
components/animated-container.tsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { motion } from "framer-motion";
4
+ import React from "react";
5
+
6
+ interface AnimatedContainerProps {
7
+ children: React.ReactNode;
8
+ delay?: number;
9
+ className?: string;
10
+ direction?: "up" | "down" | "left" | "right";
11
+ }
12
+
13
+ export function AnimatedContainer({
14
+ children,
15
+ delay = 0,
16
+ className = "",
17
+ direction = "up"
18
+ }: AnimatedContainerProps) {
19
+
20
+ const getInitial = () => {
21
+ switch (direction) {
22
+ case "up": return { opacity: 0, y: 20 };
23
+ case "down": return { opacity: 0, y: -20 };
24
+ case "left": return { opacity: 0, x: 20 };
25
+ case "right": return { opacity: 0, x: -20 };
26
+ }
27
+ };
28
+
29
+ return (
30
+ <motion.div
31
+ initial={getInitial()}
32
+ animate={{ opacity: 1, x: 0, y: 0 }}
33
+ transition={{ duration: 0.5, delay, ease: "easeOut" }}
34
+ className={className}
35
+ >
36
+ {children}
37
+ </motion.div>
38
+ );
39
+ }
components/common/confirm-dialog.tsx ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AlertDialog,
3
+ AlertDialogAction,
4
+ AlertDialogCancel,
5
+ AlertDialogContent,
6
+ AlertDialogDescription,
7
+ AlertDialogFooter,
8
+ AlertDialogHeader,
9
+ AlertDialogTitle,
10
+ } from "@/components/ui/alert-dialog";
11
+
12
+ interface ConfirmDialogProps {
13
+ open: boolean;
14
+ onOpenChange: (open: boolean) => void;
15
+ title: string;
16
+ description: string;
17
+ confirmText?: string;
18
+ cancelText?: string;
19
+ onConfirm: () => void;
20
+ variant?: "default" | "destructive";
21
+ loading?: boolean;
22
+ }
23
+
24
+ export function ConfirmDialog({
25
+ open,
26
+ onOpenChange,
27
+ title,
28
+ description,
29
+ confirmText = "Confirm",
30
+ cancelText = "Cancel",
31
+ onConfirm,
32
+ variant = "default",
33
+ loading = false,
34
+ }: ConfirmDialogProps) {
35
+ return (
36
+ <AlertDialog open={open} onOpenChange={onOpenChange}>
37
+ <AlertDialogContent>
38
+ <AlertDialogHeader>
39
+ <AlertDialogTitle>{title}</AlertDialogTitle>
40
+ <AlertDialogDescription>{description}</AlertDialogDescription>
41
+ </AlertDialogHeader>
42
+ <AlertDialogFooter>
43
+ <AlertDialogCancel disabled={loading}>{cancelText}</AlertDialogCancel>
44
+ <AlertDialogAction
45
+ onClick={(e) => {
46
+ e.preventDefault();
47
+ onConfirm();
48
+ }}
49
+ className={variant === "destructive" ? "bg-red-600 hover:bg-red-700 focus:ring-red-600" : ""}
50
+ disabled={loading}
51
+ >
52
+ {loading ? "Processing..." : confirmText}
53
+ </AlertDialogAction>
54
+ </AlertDialogFooter>
55
+ </AlertDialogContent>
56
+ </AlertDialog>
57
+ );
58
+ }
components/dashboard/business-table.tsx CHANGED
@@ -23,7 +23,12 @@ export function BusinessTable({
23
  businesses,
24
  onViewDetails,
25
  onSendEmail,
26
- }: BusinessTableProps) {
 
 
 
 
 
27
  const getStatusBadge = (status: Business["emailStatus"]) => {
28
  if (!status || status === "pending")
29
  return <Badge variant="default">Pending</Badge>;
@@ -35,11 +40,42 @@ export function BusinessTable({
35
  return <Badge variant="default">Unknown</Badge>;
36
  };
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  return (
39
  <Table>
40
  <TableHeader>
41
  <TableRow>
 
 
 
 
 
 
 
 
 
 
42
  <TableHead>Business Name</TableHead>
 
43
  <TableHead>Email</TableHead>
44
  <TableHead>Phone</TableHead>
45
  <TableHead>Category</TableHead>
@@ -50,7 +86,31 @@ export function BusinessTable({
50
  <TableBody>
51
  {businesses.map((business) => (
52
  <TableRow key={business.id}>
 
 
 
 
 
 
 
 
 
 
53
  <TableCell className="font-medium">{business.name}</TableCell>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  <TableCell>
55
  {business.email || (
56
  <span className="text-muted-foreground">No email</span>
 
23
  businesses,
24
  onViewDetails,
25
  onSendEmail,
26
+ selectedIds = [],
27
+ onSelectionChange,
28
+ }: BusinessTableProps & {
29
+ selectedIds?: string[];
30
+ onSelectionChange?: (ids: string[]) => void;
31
+ }) {
32
  const getStatusBadge = (status: Business["emailStatus"]) => {
33
  if (!status || status === "pending")
34
  return <Badge variant="default">Pending</Badge>;
 
40
  return <Badge variant="default">Unknown</Badge>;
41
  };
42
 
43
+ const handleSelectAll = (checked: boolean) => {
44
+ if (onSelectionChange) {
45
+ if (checked) {
46
+ onSelectionChange(businesses.map((b) => b.id));
47
+ } else {
48
+ onSelectionChange([]);
49
+ }
50
+ }
51
+ };
52
+
53
+ const handleSelectOne = (id: string, checked: boolean) => {
54
+ if (onSelectionChange) {
55
+ if (checked) {
56
+ onSelectionChange([...selectedIds, id]);
57
+ } else {
58
+ onSelectionChange(selectedIds.filter((sid) => sid !== id));
59
+ }
60
+ }
61
+ };
62
+
63
  return (
64
  <Table>
65
  <TableHeader>
66
  <TableRow>
67
+ {onSelectionChange && (
68
+ <TableHead className="w-[50px]">
69
+ <input
70
+ type="checkbox"
71
+ className="w-4 h-4 rounded border-gray-300"
72
+ checked={businesses.length > 0 && selectedIds.length === businesses.length}
73
+ onChange={(e) => handleSelectAll(e.target.checked)}
74
+ />
75
+ </TableHead>
76
+ )}
77
  <TableHead>Business Name</TableHead>
78
+ <TableHead>Website</TableHead>
79
  <TableHead>Email</TableHead>
80
  <TableHead>Phone</TableHead>
81
  <TableHead>Category</TableHead>
 
86
  <TableBody>
87
  {businesses.map((business) => (
88
  <TableRow key={business.id}>
89
+ {onSelectionChange && (
90
+ <TableCell>
91
+ <input
92
+ type="checkbox"
93
+ className="w-4 h-4 rounded border-gray-300"
94
+ checked={selectedIds.includes(business.id)}
95
+ onChange={(e) => handleSelectOne(business.id, e.target.checked)}
96
+ />
97
+ </TableCell>
98
+ )}
99
  <TableCell className="font-medium">{business.name}</TableCell>
100
+ <TableCell>
101
+ {business.website ? (
102
+ <a
103
+ href={business.website}
104
+ target="_blank"
105
+ rel="noopener noreferrer"
106
+ className="text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 text-sm"
107
+ >
108
+ Visit <ExternalLink className="h-3 w-3" />
109
+ </a>
110
+ ) : (
111
+ <span className="text-muted-foreground text-sm">-</span>
112
+ )}
113
+ </TableCell>
114
  <TableCell>
115
  {business.email || (
116
  <span className="text-muted-foreground">No email</span>
components/dashboard/email-chart.tsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import {
4
+ LineChart,
5
+ Line,
6
+ XAxis,
7
+ YAxis,
8
+ CartesianGrid,
9
+ Tooltip,
10
+ ResponsiveContainer,
11
+ } from "recharts";
12
+ import { Loader2 } from "lucide-react";
13
+
14
+ interface ChartDataPoint {
15
+ name: string;
16
+ sent: number;
17
+ opened: number;
18
+ }
19
+
20
+ interface EmailChartProps {
21
+ data: ChartDataPoint[];
22
+ loading: boolean;
23
+ }
24
+
25
+ export default function EmailChart({ data, loading }: EmailChartProps) {
26
+ if (loading || data.length === 0) {
27
+ return (
28
+ <div className="h-[300px] flex items-center justify-center text-muted-foreground">
29
+ {loading ? <Loader2 className="animate-spin h-6 w-6" /> : "No data available"}
30
+ </div>
31
+ );
32
+ }
33
+
34
+ return (
35
+ <ResponsiveContainer width="100%" height={300}>
36
+ <LineChart data={data}>
37
+ <CartesianGrid strokeDasharray="3 3" />
38
+ <XAxis dataKey="name" />
39
+ <YAxis />
40
+ <Tooltip />
41
+ <Line
42
+ type="monotone"
43
+ dataKey="sent"
44
+ stroke="#3b82f6"
45
+ strokeWidth={2}
46
+ />
47
+ <Line
48
+ type="monotone"
49
+ dataKey="opened"
50
+ stroke="#10b981"
51
+ strokeWidth={2}
52
+ />
53
+ </LineChart>
54
+ </ResponsiveContainer>
55
+ );
56
+ }
components/dashboard/tasks/task-card.tsx ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useSortable } from "@dnd-kit/sortable";
2
+ import { CSS } from "@dnd-kit/utilities";
3
+ import { Card, CardContent } from "@/components/ui/card";
4
+ import { Badge } from "@/components/ui/badge";
5
+ import { Button } from "@/components/ui/button";
6
+ import {
7
+ DropdownMenu,
8
+ DropdownMenuContent,
9
+ DropdownMenuItem,
10
+ DropdownMenuTrigger,
11
+ } from "@/components/ui/dropdown-menu";
12
+ import { Pause, Play, StopCircle, Trash2 } from "lucide-react";
13
+ import { Task } from "@/types";
14
+
15
+ export const priorityColors: Record<string, string> = {
16
+ low: "bg-blue-500",
17
+ medium: "bg-yellow-500",
18
+ high: "bg-red-500",
19
+ };
20
+
21
+ interface TaskCardProps {
22
+ task: Task;
23
+ onControl?: (jobId: string, action: "pause" | "resume" | "stop") => void;
24
+ onPriorityChange?: (taskId: string, priority: "low" | "medium" | "high") => void;
25
+ onDelete?: (taskId: string, type: "scraping" | "workflow") => void;
26
+ controllingTaskId?: string | null;
27
+ }
28
+
29
+ export function TaskCard({
30
+ task,
31
+ onControl,
32
+ onPriorityChange,
33
+ onDelete,
34
+ controllingTaskId,
35
+ }: TaskCardProps) {
36
+ const showControls = ["running", "processing", "paused", "pending", "in-progress"].includes(task.status);
37
+ const isControlling = controllingTaskId === task.id;
38
+
39
+ return (
40
+ <Card className="group hover:shadow-lg transition-all duration-200 border-l-4" style={{ borderLeftColor: priorityColors[task.priority]?.replace("bg-", "") /* This is a hack, better to use proper colors */ }}>
41
+ {/* Actually, let's just keep the original card style but enhance hover */}
42
+ <div className={`absolute left-0 top-0 bottom-0 w-1 rounded-l-md ${priorityColors[task.priority]}`} />
43
+
44
+ <CardContent className="p-4 space-y-3 relative pl-5">
45
+ {/* Added pl-5 for the colored bar */}
46
+
47
+ <div className="flex items-start justify-between">
48
+ <div className="flex-1">
49
+ <h4 className="font-medium flex items-center gap-2 text-foreground">
50
+ {task.title}
51
+ {task.type === "workflow" && (
52
+ <Badge variant="secondary" className="text-[10px] h-5 px-1.5">Workflow</Badge>
53
+ )}
54
+ </h4>
55
+ {task.description && (
56
+ <p className="text-sm text-muted-foreground mt-1 line-clamp-2">
57
+ {task.description}
58
+ </p>
59
+ )}
60
+ </div>
61
+ </div>
62
+
63
+ <div className="flex items-center gap-2 flex-wrap pt-2">
64
+ <DropdownMenu>
65
+ <DropdownMenuTrigger asChild>
66
+ <Badge variant="outline" className="text-xs cursor-pointer hover:bg-muted transition-colors px-2 py-0.5 h-6">
67
+ <div className={`h-2 w-2 rounded-full ${priorityColors[task.priority]} mr-1.5`} />
68
+ <span className="capitalize">{task.priority || "medium"}</span>
69
+ </Badge>
70
+ </DropdownMenuTrigger>
71
+ <DropdownMenuContent align="start">
72
+ <DropdownMenuItem onClick={() => onPriorityChange?.(task.id, "low")}>
73
+ <div className={`h-2 w-2 rounded-full ${priorityColors.low} mr-2`} />
74
+ Low
75
+ </DropdownMenuItem>
76
+ <DropdownMenuItem onClick={() => onPriorityChange?.(task.id, "medium")}>
77
+ <div className={`h-2 w-2 rounded-full ${priorityColors.medium} mr-2`} />
78
+ Medium
79
+ </DropdownMenuItem>
80
+ <DropdownMenuItem onClick={() => onPriorityChange?.(task.id, "high")}>
81
+ <div className={`h-2 w-2 rounded-full ${priorityColors.high} mr-2`} />
82
+ High
83
+ </DropdownMenuItem>
84
+ </DropdownMenuContent>
85
+ </DropdownMenu>
86
+
87
+ <div className="text-xs text-muted-foreground ml-auto">
88
+ {new Date(task.createdAt).toLocaleDateString()}
89
+ </div>
90
+ </div>
91
+
92
+ {/* Action Bar - Reveals on Group Hover or if Controls Active? No, always visible for usability */}
93
+ <div className="flex items-center justify-end gap-1 pt-2 border-t mt-2">
94
+ {showControls && onControl && (
95
+ <div className="flex gap-1">
96
+ {(task.status === "in-progress" || task.status === "processing" || task.status === "running") && (
97
+ <Button
98
+ size="sm"
99
+ variant="ghost"
100
+ onClick={() => onControl(task.id, "pause")}
101
+ disabled={isControlling}
102
+ className="h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600"
103
+ title="Pause"
104
+ >
105
+ <Pause className="h-4 w-4" />
106
+ </Button>
107
+ )}
108
+
109
+ {task.status === "paused" && (
110
+ <Button
111
+ size="sm"
112
+ variant="ghost"
113
+ onClick={() => onControl(task.id, "resume")}
114
+ disabled={isControlling}
115
+ className="h-8 w-8 p-0 hover:bg-green-50 hover:text-green-600"
116
+ title="Resume"
117
+ >
118
+ <Play className="h-4 w-4" />
119
+ </Button>
120
+ )}
121
+
122
+ <Button
123
+ size="sm"
124
+ variant="ghost"
125
+ onClick={() => onControl(task.id, "stop")}
126
+ disabled={isControlling}
127
+ className="h-8 w-8 p-0 hover:bg-red-50 hover:text-red-600"
128
+ title="Stop"
129
+ >
130
+ <StopCircle className="h-4 w-4" />
131
+ </Button>
132
+ </div>
133
+ )}
134
+
135
+ <Button
136
+ size="sm"
137
+ variant="ghost"
138
+ onClick={() => onDelete?.(task.id, task.type || "scraping")}
139
+ className="h-8 w-8 p-0 text-muted-foreground hover:bg-red-50 hover:text-red-600 ml-1"
140
+ title="Delete"
141
+ >
142
+ <Trash2 className="h-4 w-4" />
143
+ </Button>
144
+ </div>
145
+ </CardContent>
146
+ </Card>
147
+ );
148
+ }
149
+
150
+ export function SortableTaskCard(props: TaskCardProps) {
151
+ const {
152
+ attributes,
153
+ listeners,
154
+ setNodeRef,
155
+ transform,
156
+ transition,
157
+ isDragging
158
+ } = useSortable({ id: props.task.id });
159
+
160
+ const style = {
161
+ transform: CSS.Transform.toString(transform),
162
+ transition,
163
+ opacity: isDragging ? 0.5 : 1,
164
+ zIndex: isDragging ? 50 : "auto",
165
+ position: "relative" as const,
166
+ };
167
+
168
+ return (
169
+ <div ref={setNodeRef} style={style} {...attributes} {...listeners} className="touch-none">
170
+ <TaskCard {...props} />
171
+ </div>
172
+ );
173
+ }
components/dashboard/tasks/task-column.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from "react";
2
+ import { useSortable } from "@dnd-kit/sortable";
3
+ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
4
+ import { SortableTaskCard } from "./task-card";
5
+ import { Task } from "@/types";
6
+
7
+ interface TaskColumnProps {
8
+ id: string;
9
+ title: string;
10
+ icon: React.ReactNode;
11
+ tasks: Task[];
12
+ count: number;
13
+ onControl?: (jobId: string, action: "pause" | "resume" | "stop") => void;
14
+ onPriorityChange?: (taskId: string, priority: "low" | "medium" | "high") => void;
15
+ onDelete?: (taskId: string, type: "scraping" | "workflow") => void;
16
+ controllingTaskId?: string | null;
17
+ contentClassName?: string;
18
+ }
19
+
20
+ export function TaskColumn({
21
+ id,
22
+ title,
23
+ icon,
24
+ tasks,
25
+ count,
26
+ onControl,
27
+ onPriorityChange,
28
+ onDelete,
29
+ controllingTaskId,
30
+ contentClassName,
31
+ }: TaskColumnProps) {
32
+ const { setNodeRef } = useSortable({ id });
33
+
34
+ return (
35
+ <div
36
+ ref={setNodeRef}
37
+ className="space-y-4 bg-muted/40 p-4 rounded-xl min-h-[500px] border border-border/50 shadow-sm"
38
+ >
39
+ <div className="flex items-center justify-between mb-2">
40
+ <h3 className="font-semibold text-lg flex items-center gap-2 text-foreground/90">
41
+ <span className="bg-background p-2 rounded-md shadow-sm text-primary">
42
+ {icon}
43
+ </span>
44
+ {title}
45
+ </h3>
46
+ <span className="bg-primary/10 text-primary text-xs font-bold px-2.5 py-1 rounded-full">
47
+ {count}
48
+ </span>
49
+ </div>
50
+
51
+ <SortableContext
52
+ items={tasks.map((t) => t.id)}
53
+ strategy={verticalListSortingStrategy}
54
+ >
55
+ <div className={`space-y-3 min-h-[100px] ${contentClassName || ""}`}>
56
+ {tasks.map((task: Task) => (
57
+ <SortableTaskCard
58
+ key={task.id}
59
+ task={task}
60
+ onControl={onControl}
61
+ onPriorityChange={onPriorityChange}
62
+ onDelete={onDelete}
63
+ controllingTaskId={controllingTaskId}
64
+ />
65
+ ))}
66
+ {tasks.length === 0 && (
67
+ <div className="flex flex-col items-center justify-center py-12 text-muted-foreground bg-background/50 rounded-lg border border-dashed border-border/60">
68
+ <p className="text-sm font-medium">No tasks</p>
69
+ <p className="text-xs opacity-70">Drag items here</p>
70
+ </div>
71
+ )}
72
+ </div>
73
+ </SortableContext>
74
+ </div>
75
+ );
76
+ }
components/demo-notifications.tsx CHANGED
@@ -15,6 +15,8 @@ export function DemoNotifications() {
15
  message: "Your dashboard is ready. Start by creating your first campaign.",
16
  autoClose: true,
17
  duration: 6000,
 
 
18
  });
19
  }, 2000);
20
 
 
15
  message: "Your dashboard is ready. Start by creating your first campaign.",
16
  autoClose: true,
17
  duration: 6000,
18
+ link: "/dashboard/tasks",
19
+ actionLabel: "Get Started"
20
  });
21
  }, 2000);
22
 
components/info-banner.tsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { X } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+
7
+ interface InfoBannerProps {
8
+ message: string;
9
+ id: string;
10
+ }
11
+
12
+ export function InfoBanner({ message, id }: InfoBannerProps) {
13
+ const [isVisible, setIsVisible] = useState(false); // Default hidden to avoid flash
14
+ const [shouldRender, setShouldRender] = useState(false);
15
+
16
+ useEffect(() => {
17
+ // Delay check slightly to allow mount and avoid synchronous update warning
18
+ // This also ensures we are safely on the client
19
+ const timer = setTimeout(() => {
20
+ const dismissed = localStorage.getItem(`banner-dismissed-${id}`);
21
+ if (!dismissed) {
22
+ setIsVisible(true);
23
+ }
24
+ setShouldRender(true);
25
+ }, 0);
26
+ return () => clearTimeout(timer);
27
+ }, [id]);
28
+
29
+ const handleDismiss = () => {
30
+ setIsVisible(false);
31
+ localStorage.setItem(`banner-dismissed-${id}`, "true");
32
+ };
33
+
34
+ if (!shouldRender || !isVisible) return null;
35
+
36
+ return (
37
+ <div className="bg-primary/10 text-primary px-4 py-2 relative flex items-center overflow-hidden border-b border-primary/20">
38
+ <div className="flex-1 overflow-hidden whitespace-nowrap">
39
+ <div className="animate-marquee inline-block whitespace-nowrap">
40
+ <span className="mr-8 font-medium">{message}</span>
41
+ <span className="mr-8 font-medium">{message}</span>
42
+ <span className="mr-8 font-medium">{message}</span>
43
+ <span className="mr-8 font-medium">{message}</span>
44
+ </div>
45
+ </div>
46
+ <Button
47
+ variant="ghost"
48
+ size="icon"
49
+ className="h-6 w-6 ml-2 hover:bg-primary/20 absolute right-2 z-10"
50
+ onClick={handleDismiss}
51
+ >
52
+ <X className="h-4 w-4" />
53
+ </Button>
54
+ </div>
55
+ );
56
+ }
components/notification-bell.tsx CHANGED
@@ -1,6 +1,6 @@
1
  "use client";
2
 
3
- import { useState, useEffect } from "react";
4
  import { Bell } from "lucide-react";
5
  import { Button } from "@/components/ui/button";
6
  import {
@@ -9,75 +9,34 @@ import {
9
  PopoverTrigger,
10
  } from "@/components/ui/popover";
11
  import { cn } from "@/lib/utils";
 
 
12
 
13
- interface Notification {
14
- id: string;
15
- title: string;
16
- message: string;
17
- type: "info" | "success" | "warning" | "error";
18
- timestamp: Date;
19
- read: boolean;
20
- }
21
 
22
  export function NotificationBell() {
23
- const [notifications, setNotifications] = useState<Notification[]>([]);
24
- const [showBadge, setShowBadge] = useState(false);
 
 
 
 
 
 
25
  const [animate, setAnimate] = useState(false);
 
26
 
27
  useEffect(() => {
28
- // Load notifications from localStorage
29
- const saved = localStorage.getItem("autoloop-notifications");
30
- if (saved) {
31
- const parsed = JSON.parse(saved);
32
- setNotifications(parsed.map((n: Notification) => ({
33
- ...n,
34
- timestamp: new Date(n.timestamp)
35
- })));
36
  }
37
-
38
- // Listen for new notifications
39
- const handleNewNotification = (event: CustomEvent<Notification>) => {
40
- const newNotification = event.detail;
41
- setNotifications(prev => [newNotification, ...prev]);
42
-
43
- // Trigger badge animation
44
- setShowBadge(true);
45
- setAnimate(true);
46
- setTimeout(() => setAnimate(false), 1000);
47
-
48
- // Save to localStorage
49
- const updated = [newNotification, ...notifications];
50
- localStorage.setItem("autoloop-notifications", JSON.stringify(updated.slice(0, 50)));
51
- };
52
-
53
- window.addEventListener('new-notification' as any, handleNewNotification as any);
54
- return () => {
55
- window.removeEventListener('new-notification' as any, handleNewNotification as any);
56
- };
57
- }, [notifications]);
58
-
59
- const unreadCount = notifications.filter(n => !n.read).length;
60
-
61
- const markAsRead = (id: string) => {
62
- const updated = notifications.map(n =>
63
- n.id === id ? { ...n, read: true } : n
64
- );
65
- setNotifications(updated);
66
- localStorage.setItem("autoloop-notifications", JSON.stringify(updated));
67
- };
68
-
69
- const markAllAsRead = () => {
70
- const updated = notifications.map(n => ({ ...n, read: true }));
71
- setNotifications(updated);
72
- localStorage.setItem("autoloop-notifications", JSON.stringify(updated));
73
- setShowBadge(false);
74
- };
75
-
76
- const clearAll = () => {
77
- setNotifications([]);
78
- localStorage.removeItem("autoloop-notifications");
79
- setShowBadge(false);
80
- };
81
 
82
  return (
83
  <Popover>
@@ -132,15 +91,18 @@ export function NotificationBell() {
132
  <p className="text-sm">No notifications yet</p>
133
  </div>
134
  ) : (
135
- <div className="divide-y">
136
  {notifications.map((notification) => (
137
  <div
138
  key={notification.id}
139
  className={cn(
140
- "p-4 hover:bg-accent cursor-pointer transition-colors",
141
  !notification.read && "bg-blue-50 dark:bg-blue-950/20"
142
  )}
143
- onClick={() => markAsRead(notification.id)}
 
 
 
144
  >
145
  <div className="flex items-start gap-3">
146
  <div className={cn(
@@ -183,14 +145,14 @@ function formatTimestamp(date: Date): string {
183
  }
184
 
185
  // Helper function to trigger notifications from anywhere in the app
186
- export function sendNotification(notification: Omit<Notification, "id" | "timestamp" | "read">) {
187
- const event = new CustomEvent('new-notification', {
188
- detail: {
189
- ...notification,
190
- id: Date.now().toString(),
191
- timestamp: new Date(),
192
- read: false,
193
- }
194
- });
195
- window.dispatchEvent(event);
196
  }
 
1
  "use client";
2
 
3
+ import { useState, useEffect, useRef } from "react";
4
  import { Bell } from "lucide-react";
5
  import { Button } from "@/components/ui/button";
6
  import {
 
9
  PopoverTrigger,
10
  } from "@/components/ui/popover";
11
  import { cn } from "@/lib/utils";
12
+ import { useNotificationStore } from "@/store/notifications";
13
+
14
 
 
 
 
 
 
 
 
 
15
 
16
  export function NotificationBell() {
17
+ const {
18
+ notifications,
19
+ unreadCount,
20
+ markAsRead,
21
+ markAllAsRead,
22
+ clearAll
23
+ } = useNotificationStore();
24
+
25
  const [animate, setAnimate] = useState(false);
26
+ const prevCount = useRef(unreadCount);
27
 
28
  useEffect(() => {
29
+ // Only animate if unread count INCREASED
30
+ if (unreadCount > prevCount.current) {
31
+ // Defer animation trigger to avoid synchronous state update warning
32
+ const timer = setTimeout(() => {
33
+ setAnimate(true);
34
+ setTimeout(() => setAnimate(false), 1000);
35
+ }, 0);
36
+ return () => clearTimeout(timer);
37
  }
38
+ prevCount.current = unreadCount;
39
+ }, [unreadCount]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
 
41
  return (
42
  <Popover>
 
91
  <p className="text-sm">No notifications yet</p>
92
  </div>
93
  ) : (
94
+ <div className="divide-y relative">
95
  {notifications.map((notification) => (
96
  <div
97
  key={notification.id}
98
  className={cn(
99
+ "p-4 hover:bg-accent cursor-pointer transition-colors relative",
100
  !notification.read && "bg-blue-50 dark:bg-blue-950/20"
101
  )}
102
+ onClick={() => {
103
+ markAsRead(notification.id);
104
+ if (notification.link) window.location.href = notification.link;
105
+ }}
106
  >
107
  <div className="flex items-start gap-3">
108
  <div className={cn(
 
145
  }
146
 
147
  // Helper function to trigger notifications from anywhere in the app
148
+ export function sendNotification(notification: {
149
+ title: string;
150
+ message: string;
151
+ type: "info" | "success" | "warning" | "error";
152
+ autoClose?: boolean;
153
+ duration?: number;
154
+ link?: string;
155
+ actionLabel?: string;
156
+ }) {
157
+ useNotificationStore.getState().addNotification(notification);
158
  }
components/ui/alert-dialog.tsx ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5
+
6
+ import { cn } from "@/lib/utils"
7
+ import { buttonVariants } from "@/components/ui/button"
8
+
9
+ const AlertDialog = AlertDialogPrimitive.Root
10
+
11
+ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12
+
13
+ const AlertDialogPortal = AlertDialogPrimitive.Portal
14
+
15
+ const AlertDialogOverlay = React.forwardRef<
16
+ React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
17
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
18
+ >(({ className, ...props }, ref) => (
19
+ <AlertDialogPrimitive.Overlay
20
+ className={cn(
21
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
22
+ className
23
+ )}
24
+ {...props}
25
+ ref={ref}
26
+ />
27
+ ))
28
+ AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29
+
30
+ const AlertDialogContent = React.forwardRef<
31
+ React.ElementRef<typeof AlertDialogPrimitive.Content>,
32
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
33
+ >(({ className, ...props }, ref) => (
34
+ <AlertDialogPortal>
35
+ <AlertDialogOverlay />
36
+ <AlertDialogPrimitive.Content
37
+ ref={ref}
38
+ className={cn(
39
+ "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
40
+ className
41
+ )}
42
+ {...props}
43
+ />
44
+ </AlertDialogPortal>
45
+ ))
46
+ AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47
+
48
+ const AlertDialogHeader = ({
49
+ className,
50
+ ...props
51
+ }: React.HTMLAttributes<HTMLDivElement>) => (
52
+ <div
53
+ className={cn(
54
+ "flex flex-col space-y-2 text-center sm:text-left",
55
+ className
56
+ )}
57
+ {...props}
58
+ />
59
+ )
60
+ AlertDialogHeader.displayName = "AlertDialogHeader"
61
+
62
+ const AlertDialogFooter = ({
63
+ className,
64
+ ...props
65
+ }: React.HTMLAttributes<HTMLDivElement>) => (
66
+ <div
67
+ className={cn(
68
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
69
+ className
70
+ )}
71
+ {...props}
72
+ />
73
+ )
74
+ AlertDialogFooter.displayName = "AlertDialogFooter"
75
+
76
+ const AlertDialogTitle = React.forwardRef<
77
+ React.ElementRef<typeof AlertDialogPrimitive.Title>,
78
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
79
+ >(({ className, ...props }, ref) => (
80
+ <AlertDialogPrimitive.Title
81
+ ref={ref}
82
+ className={cn("text-lg font-semibold", className)}
83
+ {...props}
84
+ />
85
+ ))
86
+ AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87
+
88
+ const AlertDialogDescription = React.forwardRef<
89
+ React.ElementRef<typeof AlertDialogPrimitive.Description>,
90
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
91
+ >(({ className, ...props }, ref) => (
92
+ <AlertDialogPrimitive.Description
93
+ ref={ref}
94
+ className={cn("text-sm text-muted-foreground", className)}
95
+ {...props}
96
+ />
97
+ ))
98
+ AlertDialogDescription.displayName =
99
+ AlertDialogPrimitive.Description.displayName
100
+
101
+ const AlertDialogAction = React.forwardRef<
102
+ React.ElementRef<typeof AlertDialogPrimitive.Action>,
103
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
104
+ >(({ className, ...props }, ref) => (
105
+ <AlertDialogPrimitive.Action
106
+ ref={ref}
107
+ className={cn(buttonVariants(), className)}
108
+ {...props}
109
+ />
110
+ ))
111
+ AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112
+
113
+ const AlertDialogCancel = React.forwardRef<
114
+ React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
115
+ React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
116
+ >(({ className, ...props }, ref) => (
117
+ <AlertDialogPrimitive.Cancel
118
+ ref={ref}
119
+ className={cn(
120
+ buttonVariants({ variant: "outline" }),
121
+ "mt-2 sm:mt-0",
122
+ className
123
+ )}
124
+ {...props}
125
+ />
126
+ ))
127
+ AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128
+
129
+ export {
130
+ AlertDialog,
131
+ AlertDialogPortal,
132
+ AlertDialogOverlay,
133
+ AlertDialogTrigger,
134
+ AlertDialogContent,
135
+ AlertDialogHeader,
136
+ AlertDialogFooter,
137
+ AlertDialogTitle,
138
+ AlertDialogDescription,
139
+ AlertDialogAction,
140
+ AlertDialogCancel,
141
+ }
components/ui/alert.tsx ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const alertVariants = cva(
7
+ "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-background text-foreground",
12
+ destructive:
13
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
14
+ },
15
+ },
16
+ defaultVariants: {
17
+ variant: "default",
18
+ },
19
+ }
20
+ )
21
+
22
+ const Alert = React.forwardRef<
23
+ HTMLDivElement,
24
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
25
+ >(({ className, variant, ...props }, ref) => (
26
+ <div
27
+ ref={ref}
28
+ role="alert"
29
+ className={cn(alertVariants({ variant }), className)}
30
+ {...props}
31
+ />
32
+ ))
33
+ Alert.displayName = "Alert"
34
+
35
+ const AlertTitle = React.forwardRef<
36
+ HTMLParagraphElement,
37
+ React.HTMLAttributes<HTMLParagraphElement>
38
+ >(({ className, ...props }, ref) => (
39
+ <h5
40
+ ref={ref}
41
+ className={cn("mb-1 font-medium leading-none tracking-tight", className)}
42
+ {...props}
43
+ />
44
+ ))
45
+ AlertTitle.displayName = "AlertTitle"
46
+
47
+ const AlertDescription = React.forwardRef<
48
+ HTMLParagraphElement,
49
+ React.HTMLAttributes<HTMLParagraphElement>
50
+ >(({ className, ...props }, ref) => (
51
+ <div
52
+ ref={ref}
53
+ className={cn("text-sm [&_p]:leading-relaxed", className)}
54
+ {...props}
55
+ />
56
+ ))
57
+ AlertDescription.displayName = "AlertDescription"
58
+
59
+ export { Alert, AlertTitle, AlertDescription }
components/ui/button.tsx CHANGED
@@ -1,6 +1,7 @@
1
  "use client";
2
 
3
  import * as React from "react";
 
4
  import { cva, type VariantProps } from "class-variance-authority";
5
  import { cn } from "@/lib/utils";
6
 
@@ -37,9 +38,10 @@ export interface ButtonProps
37
  }
38
 
39
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
40
- ({ className, variant, size, ...props }, ref) => {
 
41
  return (
42
- <button
43
  className={cn(buttonVariants({ variant, size, className }))}
44
  ref={ref}
45
  {...props}
 
1
  "use client";
2
 
3
  import * as React from "react";
4
+ import { Slot } from "@radix-ui/react-slot";
5
  import { cva, type VariantProps } from "class-variance-authority";
6
  import { cn } from "@/lib/utils";
7
 
 
38
  }
39
 
40
  const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
41
+ ({ className, variant, size, asChild = false, ...props }, ref) => {
42
+ const Comp = asChild ? Slot : "button";
43
  return (
44
+ <Comp
45
  className={cn(buttonVariants({ variant, size, className }))}
46
  ref={ref}
47
  {...props}
components/ui/sheet.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as SheetPrimitive from "@radix-ui/react-dialog"
5
+ import { cva, type VariantProps } from "class-variance-authority"
6
+ import { X } from "lucide-react"
7
+
8
+ import { cn } from "@/lib/utils"
9
+
10
+ const Sheet = SheetPrimitive.Root
11
+
12
+ const SheetTrigger = SheetPrimitive.Trigger
13
+
14
+ const SheetClose = SheetPrimitive.Close
15
+
16
+ const SheetPortal = SheetPrimitive.Portal
17
+
18
+ const SheetOverlay = React.forwardRef<
19
+ React.ElementRef<typeof SheetPrimitive.Overlay>,
20
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
21
+ >(({ className, ...props }, ref) => (
22
+ <SheetPrimitive.Overlay
23
+ className={cn(
24
+ "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
25
+ className
26
+ )}
27
+ {...props}
28
+ ref={ref}
29
+ />
30
+ ))
31
+ SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
32
+
33
+ const sheetVariants = cva(
34
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
35
+ {
36
+ variants: {
37
+ side: {
38
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
39
+ bottom:
40
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
41
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
42
+ right:
43
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
44
+ },
45
+ },
46
+ defaultVariants: {
47
+ side: "right",
48
+ },
49
+ }
50
+ )
51
+
52
+ interface SheetContentProps
53
+ extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
54
+ VariantProps<typeof sheetVariants> {}
55
+
56
+ const SheetContent = React.forwardRef<
57
+ React.ElementRef<typeof SheetPrimitive.Content>,
58
+ SheetContentProps
59
+ >(({ side = "right", className, children, ...props }, ref) => (
60
+ <SheetPortal>
61
+ <SheetOverlay />
62
+ <SheetPrimitive.Content
63
+ ref={ref}
64
+ className={cn(sheetVariants({ side }), className)}
65
+ {...props}
66
+ >
67
+ <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
68
+ <X className="h-4 w-4" />
69
+ <span className="sr-only">Close</span>
70
+ </SheetPrimitive.Close>
71
+ {children}
72
+ </SheetPrimitive.Content>
73
+ </SheetPortal>
74
+ ))
75
+ SheetContent.displayName = SheetPrimitive.Content.displayName
76
+
77
+ const SheetHeader = ({
78
+ className,
79
+ ...props
80
+ }: React.HTMLAttributes<HTMLDivElement>) => (
81
+ <div
82
+ className={cn(
83
+ "flex flex-col space-y-2 text-center sm:text-left",
84
+ className
85
+ )}
86
+ {...props}
87
+ />
88
+ )
89
+ SheetHeader.displayName = "SheetHeader"
90
+
91
+ const SheetFooter = ({
92
+ className,
93
+ ...props
94
+ }: React.HTMLAttributes<HTMLDivElement>) => (
95
+ <div
96
+ className={cn(
97
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
98
+ className
99
+ )}
100
+ {...props}
101
+ />
102
+ )
103
+ SheetFooter.displayName = "SheetFooter"
104
+
105
+ const SheetTitle = React.forwardRef<
106
+ React.ElementRef<typeof SheetPrimitive.Title>,
107
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
108
+ >(({ className, ...props }, ref) => (
109
+ <SheetPrimitive.Title
110
+ ref={ref}
111
+ className={cn("text-lg font-semibold text-foreground", className)}
112
+ {...props}
113
+ />
114
+ ))
115
+ SheetTitle.displayName = SheetPrimitive.Title.displayName
116
+
117
+ const SheetDescription = React.forwardRef<
118
+ React.ElementRef<typeof SheetPrimitive.Description>,
119
+ React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
120
+ >(({ className, ...props }, ref) => (
121
+ <SheetPrimitive.Description
122
+ ref={ref}
123
+ className={cn("text-sm text-muted-foreground", className)}
124
+ {...props}
125
+ />
126
+ ))
127
+ SheetDescription.displayName = SheetPrimitive.Description.displayName
128
+
129
+ export {
130
+ Sheet,
131
+ SheetPortal,
132
+ SheetOverlay,
133
+ SheetTrigger,
134
+ SheetClose,
135
+ SheetContent,
136
+ SheetHeader,
137
+ SheetFooter,
138
+ SheetTitle,
139
+ SheetDescription,
140
+ }
components/ui/sonner.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import { useTheme } from "@/components/theme-provider"
4
+ import { Toaster as Sonner } from "sonner"
5
+
6
+ type ToasterProps = React.ComponentProps<typeof Sonner>
7
+
8
+ const Toaster = ({ ...props }: ToasterProps) => {
9
+ const { theme = "system" } = useTheme()
10
+
11
+ return (
12
+ <Sonner
13
+ theme={theme as ToasterProps["theme"]}
14
+ className="toaster group"
15
+ toastOptions={{
16
+ classNames: {
17
+ toast:
18
+ "group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
19
+ description: "group-[.toast]:text-muted-foreground",
20
+ actionButton:
21
+ "group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
22
+ cancelButton:
23
+ "group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
24
+ },
25
+ }}
26
+ {...props}
27
+ />
28
+ )
29
+ }
30
+
31
+ export { Toaster }
db/schema/index.ts CHANGED
@@ -103,3 +103,21 @@ export const feedback = pgTable("feedback", {
103
  status: varchar("status", { length: 20 }).default("new").notNull(),
104
  createdAt: timestamp("created_at").defaultNow().notNull(),
105
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  status: varchar("status", { length: 20 }).default("new").notNull(),
104
  createdAt: timestamp("created_at").defaultNow().notNull(),
105
  });
106
+
107
+ export const notifications = pgTable("notifications", {
108
+ id: text("id").primaryKey().$defaultFn(() => nanoid()),
109
+ userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
110
+ title: text("title").notNull(),
111
+ message: text("message").notNull(),
112
+ type: varchar("type", { length: 20 }).default("info").notNull(),
113
+ read: boolean("read").default(false).notNull(),
114
+ createdAt: timestamp("created_at").defaultNow().notNull(),
115
+ });
116
+
117
+ export const banners = pgTable("banners", {
118
+ id: text("id").primaryKey().$defaultFn(() => nanoid()),
119
+ message: text("message").notNull(),
120
+ isActive: boolean("is_active").default(true).notNull(),
121
+ createdAt: timestamp("created_at").defaultNow().notNull(),
122
+ createdBy: text("created_by").references(() => users.id),
123
+ });
lib/scraper-real.ts CHANGED
@@ -1,4 +1,4 @@
1
- import puppeteer from "puppeteer";
2
  import { Business } from "@/types";
3
  import { db } from "@/db";
4
  import { businesses, scrapingJobs } from "@/db/schema";
@@ -85,9 +85,11 @@ export async function scrapeGoogleMapsReal(
85
  // Try to find email and website by clicking on the business
86
  try {
87
  const businessName = business.name;
88
- const businessLink = await page.$(
89
- `a:has-text("${businessName}")`
90
- );
 
 
91
 
92
  if (businessLink) {
93
  await businessLink.click();
@@ -95,12 +97,15 @@ export async function scrapeGoogleMapsReal(
95
 
96
  // Extract additional details
97
  const details = await page.evaluate(() => {
98
- const websiteEl = document.querySelector(
99
- 'a[data-item-id="authority"]'
100
- );
101
- const phoneEl = document.querySelector(
102
- 'button[data-item-id^="phone"]'
103
- );
 
 
 
104
 
105
  return {
106
  website: websiteEl?.getAttribute("href") || null,
 
1
+ import puppeteer, { ElementHandle } from "puppeteer";
2
  import { Business } from "@/types";
3
  import { db } from "@/db";
4
  import { businesses, scrapingJobs } from "@/db/schema";
 
85
  // Try to find email and website by clicking on the business
86
  try {
87
  const businessName = business.name;
88
+ const handle = await page.evaluateHandle((name) => {
89
+ const links = Array.from(document.querySelectorAll("a"));
90
+ return links.find((el) => el.textContent?.includes(name)) || null;
91
+ }, businessName);
92
+ const businessLink = handle.asElement() as ElementHandle<Element> | null;
93
 
94
  if (businessLink) {
95
  await businessLink.click();
 
97
 
98
  // Extract additional details
99
  const details = await page.evaluate(() => {
100
+ const websiteEl =
101
+ document.querySelector('a[data-item-id="authority"]') ||
102
+ document.querySelector('a[data-tooltip="Open website"]') ||
103
+ document.querySelector('a[aria-label*="Website"]');
104
+
105
+ const phoneEl =
106
+ document.querySelector('button[data-item-id^="phone"]') ||
107
+ document.querySelector('button[data-tooltip="Copy phone number"]') ||
108
+ document.querySelector('button[aria-label*="Phone"]');
109
 
110
  return {
111
  website: websiteEl?.getAttribute("href") || null,
lib/scrapers/facebook.ts ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import puppeteer from "puppeteer";
2
+ import type { ScrapingOptions, BusinessData, ScraperSource } from "./types";
3
+
4
+ export const facebookScraper: ScraperSource = {
5
+ name: "facebook",
6
+ displayName: "Facebook",
7
+ enabled: true,
8
+
9
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
10
+ async scrape(options: ScrapingOptions, _userId: string): Promise<BusinessData[]> {
11
+ const { keywords, location, limit = 20 } = options;
12
+ const businesses: BusinessData[] = [];
13
+
14
+ console.log(`🔍 Facebook Scraper: Searching for "${keywords.join(", ")}" in ${location}`);
15
+
16
+ try {
17
+ const browser = await puppeteer.launch({
18
+ headless: true,
19
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
20
+ });
21
+
22
+ const page = await browser.newPage();
23
+ await page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
24
+
25
+ for (const keyword of keywords) {
26
+ if (businesses.length >= limit) break;
27
+
28
+ const query = `site:facebook.com ${keyword} ${location}`;
29
+ const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`;
30
+
31
+ await page.goto(searchUrl, { waitUntil: "networkidle2" });
32
+
33
+ const results = await page.evaluate(() => {
34
+ const items: BusinessData[] = [];
35
+ const searchResults = document.querySelectorAll(".g, .tF2Cxc");
36
+
37
+ searchResults.forEach((result) => {
38
+ try {
39
+ const titleEl = result.querySelector("h3");
40
+ const linkEl = result.querySelector("a");
41
+ const snippetEl = result.querySelector(".VwiC3b, .aCOpRe");
42
+
43
+ if (!titleEl || !linkEl) return;
44
+
45
+ let name = titleEl.textContent?.trim() || "";
46
+ name = name.replace(/ \| Facebook$/, "").replace(/ - Home \| Facebook$/, "");
47
+
48
+ const website = linkEl.getAttribute("href") || "";
49
+ const description = snippetEl?.textContent?.trim() || "";
50
+
51
+ if (name && website.includes("facebook.com")) {
52
+ items.push({
53
+ name,
54
+ website,
55
+ description: description.substring(0, 200),
56
+ source: "facebook",
57
+ sourceUrl: website,
58
+ address: "Facebook Page",
59
+ category: "Social Page"
60
+ });
61
+ }
62
+ } catch { }
63
+ });
64
+ return items;
65
+ });
66
+
67
+ businesses.push(...results);
68
+ await new Promise((resolve) => setTimeout(resolve, 1000 + Math.random() * 2000));
69
+ }
70
+
71
+ await browser.close();
72
+ return businesses.slice(0, limit);
73
+ } catch (error) {
74
+ console.error("❌ Facebook scraper failed:", error);
75
+ return businesses;
76
+ }
77
+ },
78
+ };
lib/scrapers/index.ts CHANGED
@@ -6,14 +6,17 @@ import { googleSearchScraper } from "./google-search";
6
  * Scraper Manager - orchestrates multiple scraping sources
7
  */
8
 
 
 
 
 
9
  // Registry of all available scrapers
10
  const scrapers = {
11
  "google-maps": googleMapsScraper,
12
  "google-search": googleSearchScraper,
13
- // Future scrapers:
14
- // "linkedin": linkedinScraper,
15
- // "facebook": facebookScraper,
16
- // "instagram": instagramScraper,
17
  };
18
 
19
  export interface MultiSourceScrapingOptions extends ScrapingOptions {
 
6
  * Scraper Manager - orchestrates multiple scraping sources
7
  */
8
 
9
+ import { linkedinScraper } from "./linkedin";
10
+ import { facebookScraper } from "./facebook";
11
+ import { instagramScraper } from "./instagram";
12
+
13
  // Registry of all available scrapers
14
  const scrapers = {
15
  "google-maps": googleMapsScraper,
16
  "google-search": googleSearchScraper,
17
+ "linkedin": linkedinScraper,
18
+ "facebook": facebookScraper,
19
+ "instagram": instagramScraper,
 
20
  };
21
 
22
  export interface MultiSourceScrapingOptions extends ScrapingOptions {
lib/scrapers/instagram.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import puppeteer from "puppeteer";
2
+ import type { ScrapingOptions, BusinessData, ScraperSource } from "./types";
3
+
4
+ export const instagramScraper: ScraperSource = {
5
+ name: "instagram",
6
+ displayName: "Instagram",
7
+ enabled: true,
8
+
9
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
10
+ async scrape(options: ScrapingOptions, _userId: string): Promise<BusinessData[]> {
11
+ const { keywords, location, limit = 20 } = options;
12
+ const businesses: BusinessData[] = [];
13
+
14
+ console.log(`🔍 Instagram Scraper: Searching for "${keywords.join(", ")}" in ${location}`);
15
+
16
+ try {
17
+ const browser = await puppeteer.launch({
18
+ headless: true,
19
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
20
+ });
21
+
22
+ const page = await browser.newPage();
23
+ await page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
24
+
25
+ for (const keyword of keywords) {
26
+ if (businesses.length >= limit) break;
27
+
28
+ const query = `site:instagram.com ${keyword} ${location}`;
29
+ const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`;
30
+
31
+ await page.goto(searchUrl, { waitUntil: "networkidle2" });
32
+
33
+ const results = await page.evaluate(() => {
34
+ const items: BusinessData[] = [];
35
+ const searchResults = document.querySelectorAll(".g, .tF2Cxc");
36
+
37
+ searchResults.forEach((result) => {
38
+ try {
39
+ const titleEl = result.querySelector("h3");
40
+ const linkEl = result.querySelector("a");
41
+ const snippetEl = result.querySelector(".VwiC3b, .aCOpRe");
42
+
43
+ if (!titleEl || !linkEl) return;
44
+
45
+ let name = titleEl.textContent?.trim() || "";
46
+ name = name.replace(/ • Instagram photos and videos$/, "").replace(/ \| Instagram$/, "");
47
+ // Often Instagram titles are "Name (@username) ..."
48
+ const handleMatch = name.match(/\(@([^)]+)\)/);
49
+ if (handleMatch) {
50
+ name = handleMatch[1]; // Use handle as name if found
51
+ }
52
+
53
+ const website = linkEl.getAttribute("href") || "";
54
+ const description = snippetEl?.textContent?.trim() || "";
55
+
56
+ if (name && website.includes("instagram.com")) {
57
+ items.push({
58
+ name,
59
+ website,
60
+ description: description.substring(0, 200),
61
+ source: "instagram",
62
+ sourceUrl: website,
63
+ address: "Instagram Profile",
64
+ category: "Social Profile"
65
+ });
66
+ }
67
+ } catch { }
68
+ });
69
+ return items;
70
+ });
71
+
72
+ businesses.push(...results);
73
+ await new Promise((resolve) => setTimeout(resolve, 1000 + Math.random() * 2000));
74
+ }
75
+
76
+ await browser.close();
77
+ return businesses.slice(0, limit);
78
+ } catch (error) {
79
+ console.error("❌ Instagram scraper failed:", error);
80
+ return businesses;
81
+ }
82
+ },
83
+ };
lib/scrapers/linkedin.ts ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import puppeteer from "puppeteer";
2
+ import type { ScrapingOptions, BusinessData, ScraperSource } from "./types";
3
+
4
+ /**
5
+ * LinkedIn Scraper (via Google) - extracts company profiles from Google search results
6
+ */
7
+ export const linkedinScraper: ScraperSource = {
8
+ name: "linkedin",
9
+ displayName: "LinkedIn",
10
+ enabled: true,
11
+
12
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
13
+ async scrape(options: ScrapingOptions, _userId: string): Promise<BusinessData[]> {
14
+ const { keywords, location, limit = 20 } = options;
15
+ const businesses: BusinessData[] = [];
16
+
17
+ console.log(`🔍 LinkedIn Scraper: Searching for "${keywords.join(", ")}" in ${location}`);
18
+
19
+ try {
20
+ const browser = await puppeteer.launch({
21
+ headless: true,
22
+ args: ["--no-sandbox", "--disable-setuid-sandbox"],
23
+ });
24
+
25
+ const page = await browser.newPage();
26
+ await page.setUserAgent(
27
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
28
+ );
29
+
30
+ for (const keyword of keywords) {
31
+ if (businesses.length >= limit) break;
32
+
33
+ // Targeted search for LinkedIn Company pages
34
+ const query = `site:linkedin.com/company ${keyword} ${location}`;
35
+ const searchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`;
36
+
37
+ console.log(` Searching: ${query}`);
38
+ await page.goto(searchUrl, { waitUntil: "networkidle2" });
39
+
40
+ const results = await page.evaluate(() => {
41
+ const items: BusinessData[] = [];
42
+ const searchResults = document.querySelectorAll(".g, .tF2Cxc");
43
+
44
+ searchResults.forEach((result) => {
45
+ try {
46
+ const titleEl = result.querySelector("h3");
47
+ const linkEl = result.querySelector("a");
48
+ const snippetEl = result.querySelector(".VwiC3b, .aCOpRe");
49
+
50
+ if (!titleEl || !linkEl) return;
51
+
52
+ let name = titleEl.textContent?.trim() || "";
53
+ // Clean up LinkedIn title suffix
54
+ name = name.replace(/ \| LinkedIn$/, "").replace(/ - LinkedIn$/, "").replace(/: LinkedIn$/, "");
55
+
56
+ const website = linkEl.getAttribute("href") || "";
57
+ const description = snippetEl?.textContent?.trim() || "";
58
+
59
+ // Validation: Only accept actual LinkedIn Company URLs
60
+ if (name && website.includes("linkedin.com/company")) {
61
+ items.push({
62
+ name,
63
+ website,
64
+ description: description.substring(0, 200),
65
+ source: "linkedin",
66
+ sourceUrl: website,
67
+ address: "LinkedIn Profile", // Placeholder
68
+ category: "Company Profile"
69
+ });
70
+ }
71
+ } catch {
72
+ // Ignore errors
73
+ }
74
+ });
75
+ return items;
76
+ });
77
+
78
+ businesses.push(...results);
79
+
80
+ // Rate limit delay
81
+ await new Promise((resolve) => setTimeout(resolve, 1000 + Math.random() * 2000));
82
+ }
83
+
84
+ await browser.close();
85
+ return businesses.slice(0, limit);
86
+ } catch (error) {
87
+ console.error("❌ LinkedIn scraper failed:", error);
88
+ return businesses;
89
+ }
90
+ },
91
+ };
package.json CHANGED
@@ -27,12 +27,14 @@
27
  "@hookform/resolvers": "^3.9.1",
28
  "@neondatabase/serverless": "^0.10.3",
29
  "@paralleldrive/cuid2": "^3.0.6",
 
30
  "@radix-ui/react-avatar": "^1.1.11",
31
  "@radix-ui/react-dialog": "^1.1.15",
32
  "@radix-ui/react-dropdown-menu": "^2.1.16",
33
  "@radix-ui/react-label": "^2.1.8",
34
  "@radix-ui/react-popover": "^1.1.15",
35
  "@radix-ui/react-select": "^2.2.6",
 
36
  "@radix-ui/react-switch": "^1.2.6",
37
  "@radix-ui/react-tabs": "^1.1.13",
38
  "@sendgrid/mail": "^8.1.6",
@@ -53,6 +55,7 @@
53
  "nanoid": "^5.0.9",
54
  "next": "16.1.3",
55
  "next-auth": "^5.0.0-beta.25",
 
56
  "nodemailer": "^7.0.12",
57
  "puppeteer": "^24.35.0",
58
  "react": "19.2.3",
 
27
  "@hookform/resolvers": "^3.9.1",
28
  "@neondatabase/serverless": "^0.10.3",
29
  "@paralleldrive/cuid2": "^3.0.6",
30
+ "@radix-ui/react-alert-dialog": "^1.1.15",
31
  "@radix-ui/react-avatar": "^1.1.11",
32
  "@radix-ui/react-dialog": "^1.1.15",
33
  "@radix-ui/react-dropdown-menu": "^2.1.16",
34
  "@radix-ui/react-label": "^2.1.8",
35
  "@radix-ui/react-popover": "^1.1.15",
36
  "@radix-ui/react-select": "^2.2.6",
37
+ "@radix-ui/react-slot": "^1.2.4",
38
  "@radix-ui/react-switch": "^1.2.6",
39
  "@radix-ui/react-tabs": "^1.1.13",
40
  "@sendgrid/mail": "^8.1.6",
 
55
  "nanoid": "^5.0.9",
56
  "next": "16.1.3",
57
  "next-auth": "^5.0.0-beta.25",
58
+ "next-themes": "^0.4.6",
59
  "nodemailer": "^7.0.12",
60
  "puppeteer": "^24.35.0",
61
  "react": "19.2.3",
pnpm-lock.yaml CHANGED
@@ -29,6 +29,9 @@ importers:
29
  '@paralleldrive/cuid2':
30
  specifier: ^3.0.6
31
  version: 3.0.6
 
 
 
32
  '@radix-ui/react-avatar':
33
  specifier: ^1.1.11
34
  version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -47,6 +50,9 @@ importers:
47
  '@radix-ui/react-select':
48
  specifier: ^2.2.6
49
  version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
 
 
 
50
  '@radix-ui/react-switch':
51
  specifier: ^1.2.6
52
  version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -107,6 +113,9 @@ importers:
107
  next-auth:
108
  specifier: ^5.0.0-beta.25
109
  version: 5.0.0-beta.30(next@16.1.3(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.12)(react@19.2.3)
 
 
 
110
  nodemailer:
111
  specifier: ^7.0.12
112
  version: 7.0.12
@@ -1105,6 +1114,19 @@ packages:
1105
  '@radix-ui/primitive@1.1.3':
1106
  resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
1107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1108
  '@radix-ui/react-arrow@1.1.7':
1109
  resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
1110
  peerDependencies:
@@ -3455,6 +3477,12 @@ packages:
3455
  nodemailer:
3456
  optional: true
3457
 
 
 
 
 
 
 
3458
  next@16.1.3:
3459
  resolution: {integrity: sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw==}
3460
  engines: {node: '>=20.9.0'}
@@ -4986,6 +5014,20 @@ snapshots:
4986
 
4987
  '@radix-ui/primitive@1.1.3': {}
4988
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4989
  '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
4990
  dependencies:
4991
  '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -7561,6 +7603,11 @@ snapshots:
7561
  optionalDependencies:
7562
  nodemailer: 7.0.12
7563
 
 
 
 
 
 
7564
  next@16.1.3(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
7565
  dependencies:
7566
  '@next/env': 16.1.3
 
29
  '@paralleldrive/cuid2':
30
  specifier: ^3.0.6
31
  version: 3.0.6
32
+ '@radix-ui/react-alert-dialog':
33
+ specifier: ^1.1.15
34
+ version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
35
  '@radix-ui/react-avatar':
36
  specifier: ^1.1.11
37
  version: 1.1.11(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
 
50
  '@radix-ui/react-select':
51
  specifier: ^2.2.6
52
  version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
53
+ '@radix-ui/react-slot':
54
+ specifier: ^1.2.4
55
+ version: 1.2.4(@types/react@19.2.8)(react@19.2.3)
56
  '@radix-ui/react-switch':
57
  specifier: ^1.2.6
58
  version: 1.2.6(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
 
113
  next-auth:
114
  specifier: ^5.0.0-beta.25
115
  version: 5.0.0-beta.30(next@16.1.3(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(nodemailer@7.0.12)(react@19.2.3)
116
+ next-themes:
117
+ specifier: ^0.4.6
118
+ version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
119
  nodemailer:
120
  specifier: ^7.0.12
121
  version: 7.0.12
 
1114
  '@radix-ui/primitive@1.1.3':
1115
  resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
1116
 
1117
+ '@radix-ui/react-alert-dialog@1.1.15':
1118
+ resolution: {integrity: sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==}
1119
+ peerDependencies:
1120
+ '@types/react': '*'
1121
+ '@types/react-dom': '*'
1122
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
1123
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
1124
+ peerDependenciesMeta:
1125
+ '@types/react':
1126
+ optional: true
1127
+ '@types/react-dom':
1128
+ optional: true
1129
+
1130
  '@radix-ui/react-arrow@1.1.7':
1131
  resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
1132
  peerDependencies:
 
3477
  nodemailer:
3478
  optional: true
3479
 
3480
+ next-themes@0.4.6:
3481
+ resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
3482
+ peerDependencies:
3483
+ react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
3484
+ react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
3485
+
3486
  next@16.1.3:
3487
  resolution: {integrity: sha512-gthG3TRD+E3/mA0uDQb9lqBmx1zVosq5kIwxNN6+MRNd085GzD+9VXMPUs+GGZCbZ+GDZdODUq4Pm7CTXK6ipw==}
3488
  engines: {node: '>=20.9.0'}
 
5014
 
5015
  '@radix-ui/primitive@1.1.3': {}
5016
 
5017
+ '@radix-ui/react-alert-dialog@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
5018
+ dependencies:
5019
+ '@radix-ui/primitive': 1.1.3
5020
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
5021
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
5022
+ '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
5023
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
5024
+ '@radix-ui/react-slot': 1.2.3(@types/react@19.2.8)(react@19.2.3)
5025
+ react: 19.2.3
5026
+ react-dom: 19.2.3(react@19.2.3)
5027
+ optionalDependencies:
5028
+ '@types/react': 19.2.8
5029
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
5030
+
5031
  '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
5032
  dependencies:
5033
  '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.8))(@types/react@19.2.8)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
 
7603
  optionalDependencies:
7604
  nodemailer: 7.0.12
7605
 
7606
+ next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
7607
+ dependencies:
7608
+ react: 19.2.3
7609
+ react-dom: 19.2.3(react@19.2.3)
7610
+
7611
  next@16.1.3(@babel/core@7.28.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
7612
  dependencies:
7613
  '@next/env': 16.1.3
store/notifications.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { create } from 'zustand';
 
2
 
3
  export type NotificationType = 'success' | 'error' | 'warning' | 'info';
4
 
@@ -11,6 +12,8 @@ export interface Notification {
11
  read: boolean;
12
  autoClose?: boolean;
13
  duration?: number;
 
 
14
  }
15
 
16
  interface NotificationStore {
@@ -42,14 +45,24 @@ export const useNotificationStore = create<NotificationStore>((set) => ({
42
  unreadCount: state.unreadCount + 1,
43
  }));
44
 
45
- // Auto-remove after duration if autoClose is true
46
- if (newNotification.autoClose && newNotification.duration) {
47
- setTimeout(() => {
48
- set((state) => ({
49
- notifications: state.notifications.filter((n) => n.id !== newNotification.id),
50
- }));
51
- }, newNotification.duration);
 
 
 
 
52
  }
 
 
 
 
 
 
53
  },
54
 
55
  removeNotification: (id) =>
 
1
  import { create } from 'zustand';
2
+ import { ExternalToast, toast } from "sonner";
3
 
4
  export type NotificationType = 'success' | 'error' | 'warning' | 'info';
5
 
 
12
  read: boolean;
13
  autoClose?: boolean;
14
  duration?: number;
15
+ link?: string;
16
+ actionLabel?: string;
17
  }
18
 
19
  interface NotificationStore {
 
45
  unreadCount: state.unreadCount + 1,
46
  }));
47
 
48
+ // Trigger Sonner toast
49
+ // Map internal types to Sonner equivalent if needed
50
+ const { type, title, message, link, actionLabel } = notification;
51
+ const toastOptions: ExternalToast = { description: message };
52
+
53
+ // Add action if link is provided
54
+ if (link) {
55
+ toastOptions.action = {
56
+ label: actionLabel || "View",
57
+ onClick: () => window.location.href = link
58
+ };
59
  }
60
+
61
+ if (type === 'success') toast.success(title, toastOptions);
62
+ else if (type === 'error') toast.error(title, toastOptions);
63
+ else if (type === 'warning') toast.warning(title, toastOptions);
64
+ else if (type === 'info') toast.info(title, toastOptions);
65
+ else toast(title, toastOptions);
66
  },
67
 
68
  removeNotification: (id) =>