Task Deletion Scraper Improvements: UI Consistency & Safety
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- app/actions/business.ts +35 -0
- app/admin/(dashboard)/businesses/page.tsx +15 -0
- app/admin/{feedback → (dashboard)/feedback}/page.tsx +6 -6
- app/admin/(dashboard)/layout.tsx +105 -0
- app/admin/(dashboard)/page.tsx +169 -0
- app/admin/(dashboard)/settings/page.tsx +142 -0
- app/admin/{users → (dashboard)/users}/page.tsx +0 -0
- app/admin/actions.ts +73 -0
- app/admin/actions/business.ts +16 -0
- app/admin/businesses/page.tsx +0 -61
- app/admin/layout.tsx +0 -87
- app/admin/login/page.tsx +2 -1
- app/admin/page.tsx +0 -103
- app/api/businesses/route.ts +22 -4
- app/api/email/send/route.ts +102 -0
- app/api/scraping/control/route.ts +6 -4
- app/api/tasks/route.ts +37 -1
- app/dashboard/businesses/page.tsx +137 -420
- app/dashboard/layout.tsx +19 -8
- app/dashboard/page.tsx +96 -55
- app/dashboard/tasks/page.tsx +118 -274
- app/layout.tsx +5 -1
- app/providers.tsx +1 -2
- components/admin/admin-analytics.tsx +130 -0
- components/admin/admin-business-view.tsx +78 -0
- components/admin/feedback-actions.tsx +56 -0
- components/admin/quick-actions.tsx +32 -0
- components/animated-container.tsx +39 -0
- components/common/confirm-dialog.tsx +58 -0
- components/dashboard/business-table.tsx +61 -1
- components/dashboard/email-chart.tsx +56 -0
- components/dashboard/tasks/task-card.tsx +173 -0
- components/dashboard/tasks/task-column.tsx +76 -0
- components/demo-notifications.tsx +2 -0
- components/info-banner.tsx +56 -0
- components/notification-bell.tsx +38 -76
- components/ui/alert-dialog.tsx +141 -0
- components/ui/alert.tsx +59 -0
- components/ui/button.tsx +4 -2
- components/ui/sheet.tsx +140 -0
- components/ui/sonner.tsx +31 -0
- db/schema/index.ts +18 -0
- lib/scraper-real.ts +15 -10
- lib/scrapers/facebook.ts +78 -0
- lib/scrapers/index.ts +7 -4
- lib/scrapers/instagram.ts +83 -0
- lib/scrapers/linkedin.ts +91 -0
- package.json +3 -0
- pnpm-lock.yaml +47 -0
- 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 |
-
|
| 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
|
| 56 |
-
|
| 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(
|
| 43 |
-
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 17 |
|
| 18 |
if (!jobId || !action) {
|
| 19 |
return NextResponse.json(
|
|
@@ -22,9 +23,10 @@ export async function POST(request: Request) {
|
|
| 22 |
);
|
| 23 |
}
|
| 24 |
|
| 25 |
-
|
|
|
|
| 26 |
return NextResponse.json(
|
| 27 |
-
{ error: "Invalid action. Must be 'pause', 'resume', or '
|
| 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 } =
|
| 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";
|
| 2 |
|
| 3 |
-
import { useState } from "react";
|
| 4 |
-
import {
|
| 5 |
-
import {
|
|
|
|
|
|
|
| 6 |
import { Button } from "@/components/ui/button";
|
| 7 |
-
import {
|
| 8 |
-
import {
|
|
|
|
|
|
|
| 9 |
import {
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 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 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
filterStatus,
|
| 47 |
-
setFilterStatus,
|
| 48 |
-
deleteBusiness,
|
| 49 |
-
updateBusiness
|
| 50 |
-
} = useBusinesses();
|
| 51 |
|
| 52 |
-
|
| 53 |
const [currentPage, setCurrentPage] = useState(1);
|
| 54 |
-
const [
|
| 55 |
-
const [
|
| 56 |
-
|
| 57 |
-
const
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
| 78 |
}
|
|
|
|
| 79 |
};
|
| 80 |
|
| 81 |
-
const
|
| 82 |
-
|
| 83 |
-
|
| 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
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
business.
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 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 |
-
|
| 137 |
-
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
| 138 |
<div>
|
| 139 |
-
<
|
| 140 |
-
<p className="text-muted-foreground">
|
| 141 |
-
Manage your scraped business leads
|
| 142 |
-
</p>
|
| 143 |
</div>
|
| 144 |
-
|
| 145 |
-
<
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
| 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 |
-
<
|
| 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 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 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 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 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 |
-
<
|
| 26 |
-
<
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 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 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
};
|
| 153 |
|
| 154 |
const handleStartScraping = async () => {
|
|
@@ -164,8 +177,12 @@ export default function DashboardPage() {
|
|
| 164 |
});
|
| 165 |
|
| 166 |
if (result) {
|
| 167 |
-
|
| 168 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
<
|
|
|
|
| 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 |
-
|
| 401 |
-
|
| 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 |
-
|
| 433 |
-
|
|
|
|
|
|
|
|
|
|
| 434 |
</CardHeader>
|
| 435 |
<CardContent>
|
| 436 |
<BusinessTable
|
| 437 |
-
|
| 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 {
|
| 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 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 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 |
-
|
|
|
|
| 129 |
}
|
| 130 |
} catch (error) {
|
| 131 |
toast.error("Failed to update priority");
|
| 132 |
console.error("Error updating priority:", error);
|
|
|
|
|
|
|
| 133 |
}
|
| 134 |
};
|
| 135 |
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
| 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
|
| 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";
|
| 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-
|
| 232 |
-
<div className="flex flex-col gap-
|
| 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-
|
| 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">
|
|
|
|
|
|
|
| 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 |
-
<
|
| 268 |
id="pending-column"
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
priorityColors={priorityColors}
|
| 279 |
-
/>
|
| 280 |
-
</SortableContext>
|
| 281 |
|
| 282 |
{/* In Progress Column */}
|
| 283 |
-
<
|
| 284 |
id="in-progress-column"
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
onControl={handleControl}
|
| 296 |
-
controllingTaskId={controllingTaskId}
|
| 297 |
-
/>
|
| 298 |
-
</SortableContext>
|
| 299 |
|
| 300 |
{/* Completed Column */}
|
| 301 |
-
<
|
| 302 |
id="completed-column"
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 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 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 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>
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
const [animate, setAnimate] = useState(false);
|
|
|
|
| 26 |
|
| 27 |
useEffect(() => {
|
| 28 |
-
//
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
const
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
}
|
| 37 |
-
|
| 38 |
-
|
| 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 |
-
|
| 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={() =>
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 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 |
-
<
|
| 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
|
| 89 |
-
|
| 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 =
|
| 99 |
-
'a[data-item-id="authority"]'
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 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 |
-
|
| 14 |
-
|
| 15 |
-
|
| 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 |
-
//
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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) =>
|