shubhjn commited on
Commit
98720a6
·
1 Parent(s): d985d08

feat: Implement a comprehensive workflow node editor with diverse node types, history management, and supporting API routes.

Browse files
app/api/businesses/categories/route.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ 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 } from "drizzle-orm";
6
+ import { SessionUser } from "@/types";
7
+
8
+ export async function GET() {
9
+ try {
10
+ const session = await auth();
11
+ if (!session?.user) {
12
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
13
+ }
14
+
15
+ const userId = (session.user as SessionUser).id;
16
+
17
+ // Fetch distinct categories
18
+ const results = await db
19
+ .selectDistinct({ category: businesses.category })
20
+ .from(businesses)
21
+ .where(eq(businesses.userId, userId))
22
+ .orderBy(businesses.category);
23
+
24
+ // Map to simple array of strings, filtering out nulls if any
25
+ const categories = results
26
+ .map((r) => r.category)
27
+ .filter((c): c is string => !!c);
28
+
29
+ return NextResponse.json({ categories });
30
+ } catch (error) {
31
+ console.error("Error fetching categories:", error);
32
+ return NextResponse.json(
33
+ { error: "Failed to fetch categories" },
34
+ { status: 500 }
35
+ );
36
+ }
37
+ }
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, sql } from "drizzle-orm";
6
  import { rateLimit } from "@/lib/rate-limit";
7
 
8
  interface SessionUser {
@@ -34,20 +34,19 @@ export async function GET(request: Request) {
34
  }
35
 
36
  if (status) {
37
- conditions.push(eq(businesses.emailStatus, status));
 
 
 
 
38
  }
39
 
40
- console.log(`🔍 Fetching businesses for UserID: ${userId}`);
41
- console.log(` Filters - Category: ${category}, Status: ${status}, Page: ${page}, Limit: ${limit}`);
42
-
43
  // Get total count
44
  const [{ count }] = await db
45
  .select({ count: sql<number>`count(*)` })
46
  .from(businesses)
47
  .where(and(...conditions));
48
 
49
- console.log(` Found ${count} total businesses matching criteria`);
50
-
51
  const totalPages = Math.ceil(count / limit);
52
 
53
  const results = await db
 
2
  import { auth } from "@/lib/auth";
3
  import { db } from "@/db";
4
  import { businesses } from "@/db/schema";
5
+ import { eq, and, sql, or, isNull } from "drizzle-orm";
6
  import { rateLimit } from "@/lib/rate-limit";
7
 
8
  interface SessionUser {
 
34
  }
35
 
36
  if (status) {
37
+ if (status === "pending") {
38
+ conditions.push(or(eq(businesses.emailStatus, "pending"), isNull(businesses.emailStatus))!);
39
+ } else {
40
+ conditions.push(eq(businesses.emailStatus, status));
41
+ }
42
  }
43
 
 
 
 
44
  // Get total count
45
  const [{ count }] = await db
46
  .select({ count: sql<number>`count(*)` })
47
  .from(businesses)
48
  .where(and(...conditions));
49
 
 
 
50
  const totalPages = Math.ceil(count / limit);
51
 
52
  const results = await db
app/api/email/send/route.ts CHANGED
@@ -1,7 +1,7 @@
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";
@@ -59,8 +59,13 @@ export async function POST(request: Request) {
59
  );
60
  }
61
 
 
 
 
 
 
62
  // Send email
63
- const success = await sendColdEmail(business, template, user.accessToken);
64
 
65
  // Update business status
66
  await db
@@ -78,15 +83,17 @@ export async function POST(request: Request) {
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
  }
 
1
  import { NextResponse } from "next/server";
2
  import { auth } from "@/lib/auth";
3
  import { db } from "@/db";
4
+ import { businesses, emailTemplates, emailLogs, users } from "@/db/schema";
5
  import { eq, and } from "drizzle-orm";
6
  import { sendColdEmail, interpolateTemplate } from "@/lib/email";
7
  import { SessionUser } from "@/types";
 
59
  );
60
  }
61
 
62
+ // Fetch user details for variable interpolation
63
+ const dbUser = await db.query.users.findFirst({
64
+ where: eq(users.id, user.id),
65
+ });
66
+
67
  // Send email
68
+ const { success, error } = await sendColdEmail(business, template, user.accessToken, dbUser);
69
 
70
  // Update business status
71
  await db
 
83
  userId: user.id,
84
  businessId: business.id,
85
  templateId: template.id,
86
+ subject: interpolateTemplate(template.subject, business, dbUser),
87
+ body: interpolateTemplate(template.body, business, dbUser),
88
  status: success ? "sent" : "failed",
89
+ errorMessage: error, // Log the error message
90
  sentAt: success ? new Date() : null,
91
  });
92
 
93
  if (!success) {
94
+ console.error("Email send failed:", error);
95
  return NextResponse.json(
96
+ { error: error || "Failed to send email via Gmail API" },
97
  { status: 500 }
98
  );
99
  }
app/api/notifications/route.ts ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { auth } from "@/lib/auth";
3
+ import { db } from "@/db";
4
+ import { notifications, users } from "@/db/schema";
5
+ import { eq, desc } from "drizzle-orm";
6
+
7
+ export async function GET() {
8
+ try {
9
+ const session = await auth();
10
+ if (!session?.user?.email) {
11
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
12
+ }
13
+
14
+ // Get user ID from DB
15
+ const user = await db.query.users.findFirst({
16
+ where: eq(users.email, session.user.email)
17
+ });
18
+
19
+ if (!user) {
20
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
21
+ }
22
+
23
+ const userNotifications = await db
24
+ .select()
25
+ .from(notifications)
26
+ .where(eq(notifications.userId, user.id))
27
+ .orderBy(desc(notifications.createdAt))
28
+ .limit(50);
29
+
30
+ return NextResponse.json({ notifications: userNotifications });
31
+ } catch (error) {
32
+ console.error("Error fetching notifications:", error);
33
+ return NextResponse.json(
34
+ { error: "Failed to fetch notifications" },
35
+ { status: 500 }
36
+ );
37
+ }
38
+ }
39
+
40
+ export async function POST(request: Request) {
41
+ try {
42
+ const session = await auth();
43
+ if (!session?.user?.email) {
44
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
45
+ }
46
+
47
+ // Get user ID from DB
48
+ const user = await db.query.users.findFirst({
49
+ where: eq(users.email, session.user.email)
50
+ });
51
+
52
+ if (!user) {
53
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
54
+ }
55
+
56
+ const { title, message, type } = await request.json();
57
+
58
+ const [newNotification] = await db
59
+ .insert(notifications)
60
+ .values({
61
+ userId: user.id,
62
+ title,
63
+ message,
64
+ type: type || "info",
65
+ read: false,
66
+ })
67
+ .returning();
68
+
69
+ return NextResponse.json({ notification: newNotification });
70
+ } catch (error) {
71
+ console.error("Error creating notification:", error);
72
+ return NextResponse.json(
73
+ { error: "Failed to create notification" },
74
+ { status: 500 }
75
+ );
76
+ }
77
+ }
app/api/settings/delete-data/route.ts ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { auth } from "@/lib/auth";
3
+ import { db } from "@/db";
4
+ import { businesses, scrapingJobs, users } from "@/db/schema";
5
+ import { eq } from "drizzle-orm";
6
+
7
+ export async function DELETE() {
8
+ try {
9
+ const session = await auth();
10
+ if (!session?.user?.email) {
11
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
12
+ }
13
+
14
+ const currentUser = await db.query.users.findFirst({
15
+ where: eq(users.email, session.user.email)
16
+ });
17
+
18
+ if (!currentUser) {
19
+ return NextResponse.json({ error: "User not found" }, { status: 404 });
20
+ }
21
+
22
+ // Delete all businesses for the user
23
+ await db.delete(businesses).where(eq(businesses.userId, currentUser.id));
24
+
25
+ // Delete all scraping jobs for the user
26
+ await db.delete(scrapingJobs).where(eq(scrapingJobs.userId, currentUser.id));
27
+
28
+ return NextResponse.json({ success: true, message: "All scraped data deleted successfully" });
29
+ } catch (error) {
30
+ console.error("Error deleting data:", error);
31
+ return NextResponse.json(
32
+ { error: "Failed to delete data" },
33
+ { status: 500 }
34
+ );
35
+ }
36
+ }
app/api/settings/route.ts CHANGED
@@ -33,6 +33,7 @@ export async function GET() {
33
  email: users.email,
34
  image: users.image,
35
  geminiApiKey: users.geminiApiKey,
 
36
  phone: users.phone,
37
  jobTitle: users.jobTitle,
38
  company: users.company,
@@ -59,6 +60,7 @@ export async function GET() {
59
  image: user.image,
60
  geminiApiKey: maskedGeminiKey,
61
  isGeminiKeySet: !!user.geminiApiKey,
 
62
  phone: user.phone,
63
  jobTitle: user.jobTitle,
64
  company: user.company,
 
33
  email: users.email,
34
  image: users.image,
35
  geminiApiKey: users.geminiApiKey,
36
+ accessToken: users.accessToken,
37
  phone: users.phone,
38
  jobTitle: users.jobTitle,
39
  company: users.company,
 
60
  image: user.image,
61
  geminiApiKey: maskedGeminiKey,
62
  isGeminiKeySet: !!user.geminiApiKey,
63
+ isGmailConnected: !!user.accessToken,
64
  phone: user.phone,
65
  jobTitle: user.jobTitle,
66
  company: user.company,
app/api/workflows/test-node/route.ts CHANGED
@@ -12,7 +12,7 @@ export async function POST(req: Request) {
12
 
13
  // Simulate execution logic
14
  let outputContext = { ...inputContext };
15
- let logs: string[] = [];
16
  let status = "success";
17
 
18
  try {
@@ -34,8 +34,9 @@ export async function POST(req: Request) {
34
  const result = check(...values);
35
  logs.push(`Condition '${condition}' evaluated to: ${result}`);
36
  outputContext._conditionResult = !!result;
37
- } catch (e: any) {
38
- logs.push(`Error evaluating condition: ${e.message}`);
 
39
  status = "error";
40
  }
41
  }
@@ -57,8 +58,9 @@ export async function POST(req: Request) {
57
  } else {
58
  logs.push("Filter passed (true).");
59
  }
60
- } catch (e: any) {
61
- logs.push(`Error evaluating filter: ${e.message}`);
 
62
  status = "error";
63
  }
64
  }
@@ -97,6 +99,54 @@ export async function POST(req: Request) {
97
  logs.push("Merge node passed.");
98
  break;
99
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
100
  case "splitInBatches":
101
  logs.push("Loop node passed (mock).");
102
  break;
@@ -106,9 +156,10 @@ export async function POST(req: Request) {
106
  break;
107
  }
108
 
109
- } catch (error: any) {
 
110
  status = "error";
111
- logs.push(`Runtime Error: ${error.message}`);
112
  }
113
 
114
  return NextResponse.json({
 
12
 
13
  // Simulate execution logic
14
  let outputContext = { ...inputContext };
15
+ const logs: string[] = [];
16
  let status = "success";
17
 
18
  try {
 
34
  const result = check(...values);
35
  logs.push(`Condition '${condition}' evaluated to: ${result}`);
36
  outputContext._conditionResult = !!result;
37
+ } catch (e) {
38
+ const errorMessage = e instanceof Error ? e.message : String(e);
39
+ logs.push(`Error evaluating condition: ${errorMessage}`);
40
  status = "error";
41
  }
42
  }
 
58
  } else {
59
  logs.push("Filter passed (true).");
60
  }
61
+ } catch (e) {
62
+ const errorMessage = e instanceof Error ? e.message : String(e);
63
+ logs.push(`Error evaluating filter: ${errorMessage}`);
64
  status = "error";
65
  }
66
  }
 
99
  logs.push("Merge node passed.");
100
  break;
101
 
102
+ case "scraper":
103
+ const action = config?.scraperAction || "extract-emails";
104
+ const inputVar = config?.scraperInputField || "";
105
+
106
+ // Simple variable resolution for test: check if inputVar is a key in inputContext, else use raw string
107
+ // This matches the simplified eval logic of this test endpoint
108
+ const cleanVar = inputVar.replace(/^\{|\}$/g, "");
109
+ let content = inputContext[cleanVar] !== undefined ? inputContext[cleanVar] : inputVar;
110
+ // handle nested like business.website
111
+ if (cleanVar.includes(".")) {
112
+ const parts = cleanVar.split(".");
113
+ if (parts[0] === "business" && inputContext.business) {
114
+ content = inputContext.business[parts[1]];
115
+ } else if (parts[0] === "variables") {
116
+ content = inputContext[parts[1]];
117
+ }
118
+ }
119
+
120
+ const textContent = typeof content === "string" ? content : JSON.stringify(content || "");
121
+ logs.push(`Running Scraper Action: ${action}`);
122
+
123
+ if (action === "fetch-url") {
124
+ let url = textContent.trim();
125
+ if (!url.startsWith("http")) url = "https://" + url;
126
+ logs.push(`Fetching URL: ${url}`);
127
+ try {
128
+ const response = await fetch(url);
129
+ if (!response.ok) throw new Error(`Status ${response.status}`);
130
+ const html = await response.text();
131
+ outputContext.scrapedData = html;
132
+ logs.push(`Success: Fetched ${html.length} chars.`);
133
+ } catch (e) {
134
+ const errorMessage = e instanceof Error ? e.message : String(e);
135
+ logs.push(`Error fetching URL: ${errorMessage}`);
136
+ status = "error";
137
+ }
138
+ } else if (action === "extract-emails") {
139
+ const emailRegex = /([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/gi;
140
+ const matches = textContent.match(emailRegex);
141
+ const emails = matches ? [...new Set(matches)] : [];
142
+ outputContext.scrapedData = emails;
143
+ logs.push(`Extracted ${emails.length} emails: ${emails.slice(0, 3).join(", ")}...`);
144
+ } else {
145
+ logs.push(`Action ${action} simulated.`);
146
+ outputContext.scrapedData = "Simulated Result";
147
+ }
148
+ break;
149
+
150
  case "splitInBatches":
151
  logs.push("Loop node passed (mock).");
152
  break;
 
156
  break;
157
  }
158
 
159
+ } catch (error: unknown) {
160
+ const errorMessage = error instanceof Error ? error.message : String(error);
161
  status = "error";
162
+ logs.push(`Runtime Error: ${errorMessage}`);
163
  }
164
 
165
  return NextResponse.json({
app/dashboard/businesses/page.tsx CHANGED
@@ -20,6 +20,13 @@ import {
20
  AlertDialogHeader,
21
  AlertDialogTitle,
22
  } from "@/components/ui/alert-dialog";
 
 
 
 
 
 
 
23
 
24
  export default function BusinessesPage() {
25
  const [businesses, setBusinesses] = useState<Business[]>([]);
@@ -33,13 +40,34 @@ export default function BusinessesPage() {
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);
@@ -47,7 +75,7 @@ export default function BusinessesPage() {
47
  };
48
  load();
49
  return () => { mounted = false; };
50
- }, [getBusinessesApi, currentPage, limit]);
51
 
52
  const handleConfirmDelete = async () => {
53
  try {
@@ -71,11 +99,7 @@ export default function BusinessesPage() {
71
  const handleSendEmail = async (business: Business) => {
72
  const toastId = toast.loading(`Sending email to ${business.name}...`);
73
  try {
74
- const result = await sendEmailApi("/api/email/send", { businessId: business.id });
75
-
76
- if (!result) {
77
- throw new Error("Failed to send email");
78
- }
79
 
80
  toast.success(`Email sent to ${business.email}`, { id: toastId });
81
 
@@ -85,25 +109,20 @@ export default function BusinessesPage() {
85
  ? { ...b, emailStatus: "sent", emailSent: true }
86
  : b
87
  ));
88
- } catch (error) {
89
- toast.error("Failed to send email", { id: toastId });
90
- console.error(error);
91
  }
92
  };
93
 
94
  return (
95
  <div className="space-y-6 pt-6">
96
- <div className="flex justify-between items-center">
97
  <div>
98
  <h2 className="text-3xl font-bold tracking-tight">Your Businesses</h2>
99
  <p className="text-muted-foreground">Manage all your collected leads</p>
100
  </div>
101
- {selectedIds.length > 0 && (
102
- <Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
103
- <Trash2 className="mr-2 h-4 w-4" />
104
- Delete ({selectedIds.length})
105
- </Button>
106
- )}
107
  </div>
108
 
109
  <Card>
@@ -111,6 +130,48 @@ export default function BusinessesPage() {
111
  <CardTitle>All Leads ({businesses.length})</CardTitle>
112
  </CardHeader>
113
  <CardContent>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  <BusinessTable
115
  businesses={businesses}
116
  onViewDetails={handleViewDetails}
 
20
  AlertDialogHeader,
21
  AlertDialogTitle,
22
  } from "@/components/ui/alert-dialog";
23
+ import {
24
+ Select,
25
+ SelectContent,
26
+ SelectItem,
27
+ SelectTrigger,
28
+ SelectValue,
29
+ } from "@/components/ui/select";
30
 
31
  export default function BusinessesPage() {
32
  const [businesses, setBusinesses] = useState<Business[]>([]);
 
40
  const [totalPages, setTotalPages] = useState(1);
41
  const [limit] = useState(10); // Or make this adjustable
42
 
43
+ const [filterCategory, setFilterCategory] = useState("all");
44
+ const [filterStatus, setFilterStatus] = useState("all");
45
+ const [categories, setCategories] = useState<string[]>([]);
46
+
47
  const { get: getBusinessesApi } = useApi<{ businesses: Business[], totalPages: number, page: number }>();
48
+ const { get: getCategoriesApi } = useApi<{ categories: string[] }>();
49
+
50
+ // Fetch categories on mount
51
+ useEffect(() => {
52
+ const fetchCategories = async () => {
53
+ const data = await getCategoriesApi("/api/businesses/categories");
54
+ if (data?.categories) {
55
+ setCategories(data.categories);
56
+ }
57
+ };
58
+ fetchCategories();
59
+ }, [getCategoriesApi]);
60
 
61
  useEffect(() => {
62
  let mounted = true;
63
  const load = async () => {
64
+ const params = new URLSearchParams();
65
+ params.append("page", currentPage.toString());
66
+ params.append("limit", limit.toString());
67
+ if (filterCategory !== "all") params.append("category", filterCategory);
68
+ if (filterStatus !== "all") params.append("status", filterStatus);
69
+
70
+ const data = await getBusinessesApi(`/api/businesses?${params.toString()}`);
71
  if (mounted && data) {
72
  setBusinesses(data.businesses);
73
  setTotalPages(data.totalPages || 1);
 
75
  };
76
  load();
77
  return () => { mounted = false; };
78
+ }, [getBusinessesApi, currentPage, limit, filterCategory, filterStatus]);
79
 
80
  const handleConfirmDelete = async () => {
81
  try {
 
99
  const handleSendEmail = async (business: Business) => {
100
  const toastId = toast.loading(`Sending email to ${business.name}...`);
101
  try {
102
+ await sendEmailApi("/api/email/send", { businessId: business.id }, { throwOnError: true });
 
 
 
 
103
 
104
  toast.success(`Email sent to ${business.email}`, { id: toastId });
105
 
 
109
  ? { ...b, emailStatus: "sent", emailSent: true }
110
  : b
111
  ));
112
+ } catch (error: unknown) {
113
+ const errorMessage = error instanceof Error ? error.message : "Failed to send email";
114
+ toast.error(errorMessage, { id: toastId });
115
  }
116
  };
117
 
118
  return (
119
  <div className="space-y-6 pt-6">
120
+ <div className="flex justify-start items-center">
121
  <div>
122
  <h2 className="text-3xl font-bold tracking-tight">Your Businesses</h2>
123
  <p className="text-muted-foreground">Manage all your collected leads</p>
124
  </div>
125
+
 
 
 
 
 
126
  </div>
127
 
128
  <Card>
 
130
  <CardTitle>All Leads ({businesses.length})</CardTitle>
131
  </CardHeader>
132
  <CardContent>
133
+ <div className="flex justify-between items-center mb-4">
134
+ <div className="flex gap-4">
135
+
136
+ <Select
137
+ value={filterStatus}
138
+ onValueChange={setFilterStatus}
139
+ >
140
+ <SelectTrigger className="w-[180px]">
141
+ <SelectValue placeholder="All Status" />
142
+ </SelectTrigger>
143
+ <SelectContent>
144
+ <SelectItem value="all">All Status</SelectItem>
145
+ <SelectItem value="pending">Pending</SelectItem>
146
+ <SelectItem value="sent">Sent</SelectItem>
147
+ <SelectItem value="failed">Failed</SelectItem>
148
+ </SelectContent>
149
+ </Select>
150
+
151
+ <Select
152
+ value={filterCategory}
153
+ onValueChange={setFilterCategory}
154
+ >
155
+ <SelectTrigger className="w-[200px]">
156
+ <SelectValue placeholder="All Categories" />
157
+ </SelectTrigger>
158
+ <SelectContent>
159
+ <SelectItem value="all">All Categories</SelectItem>
160
+ {categories.map((category) => (
161
+ <SelectItem key={category} value={category}>
162
+ {category}
163
+ </SelectItem>
164
+ ))}
165
+ </SelectContent>
166
+ </Select>
167
+ </div>
168
+ {selectedIds.length > 0 && (
169
+ <Button variant="destructive" onClick={() => setDeleteDialogOpen(true)}>
170
+ <Trash2 className="mr-2 h-4 w-4" />
171
+ Delete ({selectedIds.length})
172
+ </Button>
173
+ )}
174
+ </div>
175
  <BusinessTable
176
  businesses={businesses}
177
  onViewDetails={handleViewDetails}
app/dashboard/page.tsx CHANGED
@@ -8,6 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/com
8
  import { Button } from "@/components/ui/button";
9
  import { Input } from "@/components/ui/input";
10
  import { Label } from "@/components/ui/label";
 
11
  import { Business } from "@/types";
12
  import {
13
  Users,
@@ -313,62 +314,52 @@ export default function DashboardPage() {
313
  <Label>Scraping Sources</Label>
314
  <div className="flex flex-wrap gap-3">
315
  <label className="flex items-center gap-2 cursor-pointer">
316
- <input
317
- type="checkbox"
318
  checked={scrapingSources.includes("google-maps")}
319
- onChange={(e) => {
320
- if (e.target.checked) setScrapingSources([...scrapingSources, "google-maps"]);
321
  else setScrapingSources(scrapingSources.filter(s => s !== "google-maps"));
322
  }}
323
- className="w-4 h-4 rounded border-gray-300"
324
  />
325
  <span className="text-sm">📍 Google Maps</span>
326
  </label>
327
  <label className="flex items-center gap-2 cursor-pointer">
328
- <input
329
- type="checkbox"
330
  checked={scrapingSources.includes("google-search")}
331
- onChange={(e) => {
332
- if (e.target.checked) setScrapingSources([...scrapingSources, "google-search"]);
333
  else setScrapingSources(scrapingSources.filter(s => s !== "google-search"));
334
  }}
335
- className="w-4 h-4 rounded border-gray-300"
336
  />
337
  <span className="text-sm">🔍 Google Search</span>
338
  </label>
339
  <label className="flex items-center gap-2 cursor-pointer">
340
- <input
341
- type="checkbox"
342
  checked={scrapingSources.includes("linkedin")}
343
- onChange={(e) => {
344
- if (e.target.checked) setScrapingSources([...scrapingSources, "linkedin"]);
345
  else setScrapingSources(scrapingSources.filter(s => s !== "linkedin"));
346
  }}
347
- className="w-4 h-4 rounded border-gray-300"
348
  />
349
  <span className="text-sm">💼 LinkedIn</span>
350
  </label>
351
  <label className="flex items-center gap-2 cursor-pointer">
352
- <input
353
- type="checkbox"
354
  checked={scrapingSources.includes("facebook")}
355
- onChange={(e) => {
356
- if (e.target.checked) setScrapingSources([...scrapingSources, "facebook"]);
357
  else setScrapingSources(scrapingSources.filter(s => s !== "facebook"));
358
  }}
359
- className="w-4 h-4 rounded border-gray-300"
360
  />
361
  <span className="text-sm">👥 Facebook</span>
362
  </label>
363
  <label className="flex items-center gap-2 cursor-pointer">
364
- <input
365
- type="checkbox"
366
  checked={scrapingSources.includes("instagram")}
367
- onChange={(e) => {
368
- if (e.target.checked) setScrapingSources([...scrapingSources, "instagram"]);
369
  else setScrapingSources(scrapingSources.filter(s => s !== "instagram"));
370
  }}
371
- className="w-4 h-4 rounded border-gray-300"
372
  />
373
  <span className="text-sm">📸 Instagram</span>
374
  </label>
 
8
  import { Button } from "@/components/ui/button";
9
  import { Input } from "@/components/ui/input";
10
  import { Label } from "@/components/ui/label";
11
+ import { Checkbox } from "@/components/ui/checkbox";
12
  import { Business } from "@/types";
13
  import {
14
  Users,
 
314
  <Label>Scraping Sources</Label>
315
  <div className="flex flex-wrap gap-3">
316
  <label className="flex items-center gap-2 cursor-pointer">
317
+ <Checkbox
 
318
  checked={scrapingSources.includes("google-maps")}
319
+ onCheckedChange={(checked) => {
320
+ if (checked) setScrapingSources([...scrapingSources, "google-maps"]);
321
  else setScrapingSources(scrapingSources.filter(s => s !== "google-maps"));
322
  }}
 
323
  />
324
  <span className="text-sm">📍 Google Maps</span>
325
  </label>
326
  <label className="flex items-center gap-2 cursor-pointer">
327
+ <Checkbox
 
328
  checked={scrapingSources.includes("google-search")}
329
+ onCheckedChange={(checked) => {
330
+ if (checked) setScrapingSources([...scrapingSources, "google-search"]);
331
  else setScrapingSources(scrapingSources.filter(s => s !== "google-search"));
332
  }}
 
333
  />
334
  <span className="text-sm">🔍 Google Search</span>
335
  </label>
336
  <label className="flex items-center gap-2 cursor-pointer">
337
+ <Checkbox
 
338
  checked={scrapingSources.includes("linkedin")}
339
+ onCheckedChange={(checked) => {
340
+ if (checked) setScrapingSources([...scrapingSources, "linkedin"]);
341
  else setScrapingSources(scrapingSources.filter(s => s !== "linkedin"));
342
  }}
 
343
  />
344
  <span className="text-sm">💼 LinkedIn</span>
345
  </label>
346
  <label className="flex items-center gap-2 cursor-pointer">
347
+ <Checkbox
 
348
  checked={scrapingSources.includes("facebook")}
349
+ onCheckedChange={(checked) => {
350
+ if (checked) setScrapingSources([...scrapingSources, "facebook"]);
351
  else setScrapingSources(scrapingSources.filter(s => s !== "facebook"));
352
  }}
 
353
  />
354
  <span className="text-sm">👥 Facebook</span>
355
  </label>
356
  <label className="flex items-center gap-2 cursor-pointer">
357
+ <Checkbox
 
358
  checked={scrapingSources.includes("instagram")}
359
+ onCheckedChange={(checked) => {
360
+ if (checked) setScrapingSources([...scrapingSources, "instagram"]);
361
  else setScrapingSources(scrapingSources.filter(s => s !== "instagram"));
362
  }}
 
363
  />
364
  <span className="text-sm">📸 Instagram</span>
365
  </label>
app/dashboard/settings/page.tsx CHANGED
@@ -27,6 +27,7 @@ import {
27
  DialogFooter,
28
  } from "@/components/ui/dialog";
29
  import { Moon, Sun, LogOut, Trash2, AlertTriangle, Palette } from "lucide-react";
 
30
 
31
  interface StatusResponse {
32
  database: boolean;
@@ -42,13 +43,17 @@ export default function SettingsPage() {
42
  const [isSavingNotifications, setIsSavingNotifications] = useState(false);
43
 
44
  // API Hooks
45
- const { get: getSettings, patch: patchSettings, loading: settingsLoading } = useApi<{ user: UserProfile & { isGeminiKeySet: boolean } }>();
46
  const { get: getStatus, loading: statusLoading } = useApi<StatusResponse>();
47
  const { del: deleteUserFn, loading: deletingUser } = useApi<void>();
 
 
 
48
 
49
  // API Key State
50
  const [geminiApiKey, setGeminiApiKey] = useState("");
51
  const [isGeminiKeySet, setIsGeminiKeySet] = useState(false);
 
52
 
53
  // Connection Status State
54
  const [connectionStatus, setConnectionStatus] = useState({ database: false, redis: false });
@@ -88,6 +93,7 @@ export default function SettingsPage() {
88
  setCustomVariables(vars);
89
  }
90
  setIsGeminiKeySet(settingsData.user.isGeminiKeySet);
 
91
  }
92
 
93
  // Fetch Connection Status
@@ -189,6 +195,25 @@ export default function SettingsPage() {
189
  }
190
  };
191
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
192
  return (
193
  <div className="space-y-6">
194
  <div>
@@ -443,6 +468,23 @@ export default function SettingsPage() {
443
  </Button>
444
  </div>
445
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
446
  <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-4 border border-destructive/20 rounded-lg bg-destructive/5">
447
  <div className="space-y-1">
448
  <div className="font-medium text-destructive">Delete Account</div>
@@ -467,23 +509,15 @@ export default function SettingsPage() {
467
  <TabsContent value="api" className="space-y-4">
468
  <Card>
469
  <CardHeader>
470
- <CardTitle>API Keys</CardTitle>
471
  <CardDescription>
472
- Manage your API keys for integrations
473
  </CardDescription>
474
  </CardHeader>
475
  <CardContent className="space-y-6">
476
- {/* Google OAuth */}
477
  <div className="space-y-2">
478
- <div className="flex items-center justify-between">
479
- <div>
480
- <Label>Google OAuth</Label>
481
- <p className="text-sm text-muted-foreground">
482
- Connected via OAuth 2.0
483
- </p>
484
- </div>
485
- <Badge variant="default">Active</Badge>
486
- </div>
487
  </div>
488
 
489
  {/* Database URL */}
@@ -499,7 +533,7 @@ export default function SettingsPage() {
499
  )}
500
  </div>
501
  <p className="text-sm text-muted-foreground">
502
- Neon PostgreSQL database
503
  </p>
504
  </div>
505
 
@@ -730,6 +764,41 @@ export default function SettingsPage() {
730
  </DialogFooter>
731
  </DialogContent>
732
  </Dialog>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
733
  </div>
734
  );
735
  }
 
27
  DialogFooter,
28
  } from "@/components/ui/dialog";
29
  import { Moon, Sun, LogOut, Trash2, AlertTriangle, Palette } from "lucide-react";
30
+ import { MailSettings } from "@/components/mail/mail-settings";
31
 
32
  interface StatusResponse {
33
  database: boolean;
 
43
  const [isSavingNotifications, setIsSavingNotifications] = useState(false);
44
 
45
  // API Hooks
46
+ const { get: getSettings, patch: patchSettings, loading: settingsLoading } = useApi<{ user: UserProfile & { isGeminiKeySet: boolean, isGmailConnected: boolean } }>();
47
  const { get: getStatus, loading: statusLoading } = useApi<StatusResponse>();
48
  const { del: deleteUserFn, loading: deletingUser } = useApi<void>();
49
+ const { del: deleteDataFn, loading: deletingData } = useApi<void>();
50
+
51
+ const [isDeleteDataModalOpen, setIsDeleteDataModalOpen] = useState(false);
52
 
53
  // API Key State
54
  const [geminiApiKey, setGeminiApiKey] = useState("");
55
  const [isGeminiKeySet, setIsGeminiKeySet] = useState(false);
56
+ const [isGmailConnected, setIsGmailConnected] = useState(false);
57
 
58
  // Connection Status State
59
  const [connectionStatus, setConnectionStatus] = useState({ database: false, redis: false });
 
93
  setCustomVariables(vars);
94
  }
95
  setIsGeminiKeySet(settingsData.user.isGeminiKeySet);
96
+ setIsGmailConnected(settingsData.user.isGmailConnected);
97
  }
98
 
99
  // Fetch Connection Status
 
195
  }
196
  };
197
 
198
+ const handleDeleteData = async () => {
199
+ const result = await deleteDataFn("/api/settings/delete-data");
200
+
201
+ if (result !== null) {
202
+ toast({
203
+ title: "Data Deleted",
204
+ description: "All scraped businesses and jobs have been permanently deleted.",
205
+ });
206
+ setIsDeleteDataModalOpen(false);
207
+ } else {
208
+ toast({
209
+ title: "Error",
210
+ description: "Failed to delete data. Please try again.",
211
+ variant: "destructive",
212
+ });
213
+ setIsDeleteDataModalOpen(false);
214
+ }
215
+ };
216
+
217
  return (
218
  <div className="space-y-6">
219
  <div>
 
468
  </Button>
469
  </div>
470
 
471
+ <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-4 border border-destructive/20 rounded-lg bg-destructive/5">
472
+ <div className="space-y-1">
473
+ <div className="font-medium text-destructive">Delete Scraped Data</div>
474
+ <div className="text-sm text-muted-foreground">
475
+ Permanently delete all scraped businesses and jobs
476
+ </div>
477
+ </div>
478
+ <Button
479
+ variant="outline"
480
+ onClick={() => setIsDeleteDataModalOpen(true)}
481
+ className="cursor-pointer border-destructive/50 text-destructive hover:bg-destructive/10"
482
+ >
483
+ <Trash2 className="mr-2 h-4 w-4" />
484
+ Delete Data
485
+ </Button>
486
+ </div>
487
+
488
  <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between p-4 border border-destructive/20 rounded-lg bg-destructive/5">
489
  <div className="space-y-1">
490
  <div className="font-medium text-destructive">Delete Account</div>
 
509
  <TabsContent value="api" className="space-y-4">
510
  <Card>
511
  <CardHeader>
512
+ <CardTitle>API Keys & Integrations</CardTitle>
513
  <CardDescription>
514
+ Manage your connections and API keys.
515
  </CardDescription>
516
  </CardHeader>
517
  <CardContent className="space-y-6">
518
+ {/* Mail Settings (Gmail) */}
519
  <div className="space-y-2">
520
+ <MailSettings isConnected={isGmailConnected} email={session?.user?.email} />
 
 
 
 
 
 
 
 
521
  </div>
522
 
523
  {/* Database URL */}
 
533
  )}
534
  </div>
535
  <p className="text-sm text-muted-foreground">
536
+ Database
537
  </p>
538
  </div>
539
 
 
764
  </DialogFooter>
765
  </DialogContent>
766
  </Dialog>
767
+
768
+ <Dialog open={isDeleteDataModalOpen} onOpenChange={setIsDeleteDataModalOpen}>
769
+ <DialogContent>
770
+ <DialogHeader>
771
+ <DialogTitle>Delete All Scraped Data</DialogTitle>
772
+ <DialogDescription>
773
+ Are you sure you want to delete all scraped businesses and jobs? This action cannot be undone.
774
+ </DialogDescription>
775
+ </DialogHeader>
776
+ <DialogFooter>
777
+ <Button
778
+ variant="outline"
779
+ onClick={() => setIsDeleteDataModalOpen(false)}
780
+ className="cursor-pointer"
781
+ >
782
+ Cancel
783
+ </Button>
784
+ <Button
785
+ variant="destructive"
786
+ onClick={handleDeleteData}
787
+ disabled={deletingData}
788
+ className="cursor-pointer"
789
+ >
790
+ {deletingData ? (
791
+ <>
792
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
793
+ Deleting...
794
+ </>
795
+ ) : (
796
+ "Delete Data"
797
+ )}
798
+ </Button>
799
+ </DialogFooter>
800
+ </DialogContent>
801
+ </Dialog>
802
  </div>
803
  );
804
  }
app/dashboard/templates/page.tsx CHANGED
@@ -6,7 +6,15 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
  import { Button } from "@/components/ui/button";
7
  import { Badge } from "@/components/ui/badge";
8
  import { EmailTemplate, UserProfile } from "@/types";
9
- import { Plus, Trash2, Star, Loader2 } from "lucide-react";
 
 
 
 
 
 
 
 
10
 
11
  import { useTemplates } from "@/hooks/use-templates";
12
  import { useToast } from "@/hooks/use-toast";
@@ -23,8 +31,9 @@ export default function TemplatesPage() {
23
  const { get: getUser } = useApi<{ user: UserProfile }>();
24
  const { post: generateAiTemplate } = useApi<{ subject: string; body: string }>();
25
 
 
 
26
  useEffect(() => {
27
- // Fetch user profile for variables
28
  const fetchProfile = async () => {
29
  const data = await getUser("/api/settings");
30
  if (data?.user) {
@@ -71,10 +80,28 @@ export default function TemplatesPage() {
71
  }
72
  };
73
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  const handleGenerateWithAI = async (prompt: string) => {
 
75
  setIsGenerating(true);
76
  const generated = await generateAiTemplate("/api/templates/generate", {
77
- businessType: "businesses", // Generic fallback
78
  purpose: prompt
79
  });
80
 
@@ -101,7 +128,6 @@ export default function TemplatesPage() {
101
 
102
  return (
103
  <div className="space-y-6">
104
- {/* Header */}
105
  <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
106
  <div>
107
  <h1 className="text-3xl font-bold">Email Templates</h1>
@@ -117,6 +143,12 @@ export default function TemplatesPage() {
117
 
118
  {isCreating ? (
119
  <div className="space-y-4">
 
 
 
 
 
 
120
  <EmailEditor
121
  template={selectedTemplate}
122
  onChange={setSelectedTemplate}
@@ -149,45 +181,55 @@ export default function TemplatesPage() {
149
  ) : (
150
  <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
151
  {templates.map((template) => (
152
- <Card key={template.id} className="group cursor-pointer hover:shadow-lg transition-all">
153
  <CardHeader>
154
  <div className="flex items-start justify-between">
155
- <CardTitle className="line-clamp-1">{template.name}</CardTitle>
156
- {template.isDefault && (
157
- <Badge variant="default">
158
- <Star className="mr-1 h-3 w-3 fill-current" />
159
- Default
160
- </Badge>
161
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  </div>
163
  </CardHeader>
164
  <CardContent>
165
  <div
166
- className="text-sm text-muted-foreground line-clamp-3 mb-4 prose prose-sm dark:prose-invert max-h-18 overflow-hidden"
167
  dangerouslySetInnerHTML={{ __html: template.body || template.subject || "No content" }}
168
  />
169
- <div className="flex gap-2 transition-all">
170
- <Button
171
- size="sm"
172
- variant="outline"
173
- onClick={() => {
174
- setSelectedTemplate(template);
175
- setIsCreating(true);
176
- }}
177
- className="hover:scale-105 transition-transform"
178
- >
179
- Edit
180
- </Button>
181
- <Button
182
- size="sm"
183
- variant="outline"
184
- onClick={(e) => handleDelete(template.id, e)}
185
- disabled={template.isDefault}
186
- className="hover:scale-105 transition-transform"
187
- >
188
- <Trash2 className="h-4 w-4" />
189
- </Button>
190
- </div>
191
  </CardContent>
192
  </Card>
193
  ))}
 
6
  import { Button } from "@/components/ui/button";
7
  import { Badge } from "@/components/ui/badge";
8
  import { EmailTemplate, UserProfile } from "@/types";
9
+ import {
10
+ DropdownMenu,
11
+ DropdownMenuContent,
12
+ DropdownMenuItem,
13
+ DropdownMenuLabel,
14
+ DropdownMenuSeparator,
15
+ DropdownMenuTrigger,
16
+ } from "@/components/ui/dropdown-menu";
17
+ import { Plus, Trash2, Star, Loader2, MoreVertical, Edit, Check, ArrowLeft } from "lucide-react";
18
 
19
  import { useTemplates } from "@/hooks/use-templates";
20
  import { useToast } from "@/hooks/use-toast";
 
31
  const { get: getUser } = useApi<{ user: UserProfile }>();
32
  const { post: generateAiTemplate } = useApi<{ subject: string; body: string }>();
33
 
34
+ // ... (existing useEffect) ...
35
+
36
  useEffect(() => {
 
37
  const fetchProfile = async () => {
38
  const data = await getUser("/api/settings");
39
  if (data?.user) {
 
80
  }
81
  };
82
 
83
+ const handleSetDefault = async (template: EmailTemplate, e: React.MouseEvent) => {
84
+ e.stopPropagation();
85
+ const success = await saveTemplate({ id: template.id, isDefault: true });
86
+ if (success) {
87
+ toast({
88
+ title: "Success",
89
+ description: "Default template updated",
90
+ });
91
+ } else {
92
+ toast({
93
+ title: "Error",
94
+ description: "Failed to set default template",
95
+ variant: "destructive",
96
+ });
97
+ }
98
+ };
99
+
100
  const handleGenerateWithAI = async (prompt: string) => {
101
+ // ... (existing logic) ...
102
  setIsGenerating(true);
103
  const generated = await generateAiTemplate("/api/templates/generate", {
104
+ businessType: "businesses",
105
  purpose: prompt
106
  });
107
 
 
128
 
129
  return (
130
  <div className="space-y-6">
 
131
  <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
132
  <div>
133
  <h1 className="text-3xl font-bold">Email Templates</h1>
 
143
 
144
  {isCreating ? (
145
  <div className="space-y-4">
146
+ <div className="flex items-center gap-2">
147
+ <Button variant="ghost" size="sm" onClick={() => setIsCreating(false)}>
148
+ <ArrowLeft className="h-4 w-4 mr-2" />
149
+ Back
150
+ </Button>
151
+ </div>
152
  <EmailEditor
153
  template={selectedTemplate}
154
  onChange={setSelectedTemplate}
 
181
  ) : (
182
  <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
183
  {templates.map((template) => (
184
+ <Card key={template.id} className="group hover:shadow-lg transition-all relative">
185
  <CardHeader>
186
  <div className="flex items-start justify-between">
187
+ <div className="flex-1 mr-2">
188
+ <CardTitle className="line-clamp-1">{template.name}</CardTitle>
189
+ {template.isDefault && (
190
+ <div className="mt-2">
191
+ <Badge variant="default" className="bg-yellow-500/15 text-yellow-700 hover:bg-yellow-500/25 border-yellow-500/50 dark:text-yellow-400">
192
+ <Star className="mr-1 h-3 w-3 fill-current" />
193
+ Default
194
+ </Badge>
195
+ </div>
196
+ )}
197
+ </div>
198
+
199
+ <DropdownMenu>
200
+ <DropdownMenuTrigger asChild>
201
+ <Button variant="ghost" size="icon" className="h-8 w-8">
202
+ <MoreVertical className="h-4 w-4" />
203
+ </Button>
204
+ </DropdownMenuTrigger>
205
+ <DropdownMenuContent align="end">
206
+ <DropdownMenuLabel>Actions</DropdownMenuLabel>
207
+ <DropdownMenuSeparator />
208
+ <DropdownMenuItem onClick={() => {
209
+ setSelectedTemplate(template);
210
+ setIsCreating(true);
211
+ }}>
212
+ <Edit className="mr-2 h-4 w-4" /> Edit
213
+ </DropdownMenuItem>
214
+ <DropdownMenuItem onClick={(e) => handleSetDefault(template, e)} disabled={template.isDefault}>
215
+ <Check className="mr-2 h-4 w-4" /> Set as Default
216
+ </DropdownMenuItem>
217
+ <DropdownMenuSeparator />
218
+ <DropdownMenuItem
219
+ onClick={(e) => handleDelete(template.id, e)}
220
+ className="text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/50"
221
+ >
222
+ <Trash2 className="mr-2 h-4 w-4" /> Delete
223
+ </DropdownMenuItem>
224
+ </DropdownMenuContent>
225
+ </DropdownMenu>
226
  </div>
227
  </CardHeader>
228
  <CardContent>
229
  <div
230
+ className="text-sm text-muted-foreground line-clamp-3 mb-2 prose prose-sm dark:prose-invert max-h-18 overflow-hidden"
231
  dangerouslySetInnerHTML={{ __html: template.body || template.subject || "No content" }}
232
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  </CardContent>
234
  </Card>
235
  ))}
components/dashboard/business-table.tsx CHANGED
@@ -12,6 +12,8 @@ import {
12
  import { Badge } from "@/components/ui/badge";
13
  import { Button } from "@/components/ui/button";
14
  import { Mail, ExternalLink, MoreHorizontal } from "lucide-react";
 
 
15
 
16
  interface BusinessTableProps {
17
  businesses: Business[];
@@ -66,11 +68,10 @@ export function BusinessTable({
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
  )}
@@ -88,12 +89,12 @@ export function BusinessTable({
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>
 
12
  import { Badge } from "@/components/ui/badge";
13
  import { Button } from "@/components/ui/button";
14
  import { Mail, ExternalLink, MoreHorizontal } from "lucide-react";
15
+ import { Checkbox } from "@/components/ui/checkbox";
16
+
17
 
18
  interface BusinessTableProps {
19
  businesses: Business[];
 
68
  <TableRow>
69
  {onSelectionChange && (
70
  <TableHead className="w-[50px]">
71
+ <Checkbox
 
 
72
  checked={businesses.length > 0 && selectedIds.length === businesses.length}
73
+ onCheckedChange={(checked) => handleSelectAll(checked as boolean)}
74
+ aria-label="Select all"
75
  />
76
  </TableHead>
77
  )}
 
89
  <TableRow key={business.id}>
90
  {onSelectionChange && (
91
  <TableCell>
92
+
93
+ <Checkbox
 
94
  checked={selectedIds.includes(business.id)}
95
+ onCheckedChange={(checked) => handleSelectOne(business.id, checked as boolean)}
96
+ />
97
+
98
  </TableCell>
99
  )}
100
  <TableCell className="font-medium">{business.name}</TableCell>
components/email/email-editor.tsx CHANGED
@@ -1,7 +1,11 @@
 
1
  import { useMemo, useRef, useState } from "react";
2
  import { EmailTemplate, Business, UserProfile } from "@/types";
3
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4
  import { Input } from "@/components/ui/input";
 
 
 
5
  import { Button } from "@/components/ui/button";
6
  import { Badge } from "@/components/ui/badge";
7
  import { Sparkles, Loader2 } from "lucide-react";
@@ -204,6 +208,16 @@ export function EmailEditor({
204
  onChange({ ...template, name: e.target.value })
205
  }
206
  />
 
 
 
 
 
 
 
 
 
 
207
  </div>
208
 
209
  {/* Subject */}
 
1
+
2
  import { useMemo, useRef, useState } from "react";
3
  import { EmailTemplate, Business, UserProfile } from "@/types";
4
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
5
  import { Input } from "@/components/ui/input";
6
+ import { Checkbox } from "@/components/ui/checkbox";
7
+ import { Label } from "@/components/ui/label";
8
+
9
  import { Button } from "@/components/ui/button";
10
  import { Badge } from "@/components/ui/badge";
11
  import { Sparkles, Loader2 } from "lucide-react";
 
208
  onChange({ ...template, name: e.target.value })
209
  }
210
  />
211
+ <div className="flex items-center space-x-2 pt-2">
212
+ <div className="flex items-center space-x-2 pt-2">
213
+ <Checkbox
214
+ id="isDefault"
215
+ checked={template.isDefault || false}
216
+ onCheckedChange={(checked) => onChange({ ...template, isDefault: checked as boolean })}
217
+ />
218
+ <Label htmlFor="isDefault">Set as Default Template</Label>
219
+ </div>
220
+ </div>
221
  </div>
222
 
223
  {/* Subject */}
components/mail/mail-settings.tsx ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { Mail, CheckCircle2, AlertTriangle, RefreshCw } from "lucide-react";
7
+ import { signIn } from "next-auth/react";
8
+
9
+ interface MailSettingsProps {
10
+ isConnected: boolean;
11
+ email?: string | null;
12
+ }
13
+
14
+ export function MailSettings({ isConnected, email }: MailSettingsProps) {
15
+ const [isLoading, setIsLoading] = useState(false);
16
+
17
+ const handleConnect = async () => {
18
+ setIsLoading(true);
19
+ try {
20
+ // Force re-authorization to get new tokens
21
+ await signIn("google", {
22
+ callbackUrl: "/dashboard/settings",
23
+ prompt: "consent"
24
+ });
25
+ } catch (error) {
26
+ console.error(error);
27
+ setIsLoading(false);
28
+ }
29
+ };
30
+
31
+ return (
32
+ <Card>
33
+ <CardHeader>
34
+ <CardTitle className="flex items-center gap-2">
35
+ <Mail className="h-5 w-5" />
36
+ Email Automation Settings
37
+ </CardTitle>
38
+ <CardDescription>
39
+ Connect your Gmail account to enable automated email sending.
40
+ </CardDescription>
41
+ </CardHeader>
42
+ <CardContent className="space-y-4">
43
+ <div className="flex items-center justify-between p-4 border rounded-lg bg-slate-50 dark:bg-slate-900 border-slate-200 dark:border-slate-800">
44
+ <div className="flex items-center gap-3">
45
+ <div className={`p-2 rounded-full ${isConnected ? "bg-green-100 text-green-600" : "bg-amber-100 text-amber-600"}`}>
46
+ {isConnected ? <CheckCircle2 className="h-5 w-5" /> : <AlertTriangle className="h-5 w-5" />}
47
+ </div>
48
+ <div>
49
+ <h4 className="font-medium text-sm">
50
+ {isConnected ? "Gmail Connected" : "Gmail Not Connected"}
51
+ </h4>
52
+ <p className="text-xs text-muted-foreground">
53
+ {isConnected
54
+ ? `Sending emails as ${email || "your account"}`
55
+ : "Connect to allow the app to send emails on your behalf"}
56
+ </p>
57
+ </div>
58
+ </div>
59
+ <Button
60
+ variant={isConnected ? "outline" : "default"}
61
+ size="sm"
62
+ onClick={handleConnect}
63
+ disabled={isLoading}
64
+ >
65
+ {isLoading ? (
66
+ <RefreshCw className="h-4 w-4 animate-spin mr-2" />
67
+ ) : (
68
+ <Mail className="h-4 w-4 mr-2" />
69
+ )}
70
+ {isConnected ? "Reconnect / Refresh" : "Connect Gmail"}
71
+ </Button>
72
+ </div>
73
+
74
+ <div className="text-xs bg-blue-50 dark:bg-blue-900/20 p-3 rounded text-blue-800 dark:text-blue-300">
75
+ <strong>Note:</strong> Loggin in with GitHub will keep these permissions active. You only need to reconnect if you see &quot;Invalid Credentials&quot; errors.
76
+ </div>
77
+ </CardContent>
78
+ </Card>
79
+ );
80
+ }
components/node-editor/node-config-dialog.tsx CHANGED
@@ -1,8 +1,14 @@
1
  import React, { useState, useEffect } from "react";
2
  import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
3
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
 
 
 
 
 
 
4
  import { ScrollArea } from "@/components/ui/scroll-area";
5
- import { Play, Loader2 } from "lucide-react";
6
  import { Button } from "@/components/ui/button";
7
  import { Input } from "@/components/ui/input";
8
  import { Label } from "@/components/ui/label";
@@ -17,6 +23,7 @@ interface NodeConfigDialogProps {
17
  onOpenChange: (open: boolean) => void;
18
  node: Node<NodeData>;
19
  onSave: (config: NodeData["config"], label?: string) => void;
 
20
  }
21
 
22
  interface EmailTemplate {
@@ -24,7 +31,84 @@ interface EmailTemplate {
24
  name: string;
25
  }
26
 
27
- export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfigDialogProps) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  const [config, setConfig] = useState<NodeData["config"]>(node.data.config || {});
29
  const [label, setLabel] = useState(node.data.label);
30
  const [templates, setTemplates] = useState<EmailTemplate[]>([]);
@@ -35,6 +119,16 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
35
 
36
  const { get, loading: loadingTemplates } = useApi<{ templates: EmailTemplate[] }>();
37
 
 
 
 
 
 
 
 
 
 
 
38
  // Fetch templates when dialog opens and node is template type
39
  useEffect(() => {
40
  if (open && node.data.type === "template") {
@@ -59,7 +153,30 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
59
  onSave(config, label);
60
  };
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  const renderConfigForm = () => {
 
 
 
63
  switch (node.data.type) {
64
  case "start":
65
  return (
@@ -74,7 +191,13 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
74
  return (
75
  <div className="space-y-4">
76
  <div className="space-y-2">
77
- <Label htmlFor="condition">Condition Expression</Label>
 
 
 
 
 
 
78
  <Input
79
  id="condition"
80
  placeholder="!website"
@@ -83,7 +206,6 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
83
  />
84
  <div className="text-xs text-muted-foreground">
85
  <p>Examples: !website (no website), email (has email), category == &quot;restaurant&quot;</p>
86
- <p className="mt-1 font-medium">Available variables: website, email, phone, rating, reviewCount</p>
87
  </div>
88
  </div>
89
  </div>
@@ -146,7 +268,13 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
146
  return (
147
  <div className="space-y-4">
148
  <div className="space-y-2">
149
- <Label htmlFor="customCode">Custom Function Code</Label>
 
 
 
 
 
 
150
  <Textarea
151
  id="customCode"
152
  placeholder="// JavaScript code here&#10;return true;"
@@ -157,7 +285,6 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
157
  />
158
  <div className="text-xs text-muted-foreground">
159
  <p>Write custom JavaScript code. Return true to continue, false to stop.</p>
160
- <p className="mt-1">Variables available: <code className="bg-muted px-1 rounded">company</code></p>
161
  </div>
162
  </div>
163
  </div>
@@ -167,7 +294,13 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
167
  return (
168
  <div className="space-y-4">
169
  <div className="space-y-2">
170
- <Label htmlFor="aiPrompt">AI Prompt</Label>
 
 
 
 
 
 
171
  <Textarea
172
  id="aiPrompt"
173
  placeholder="Generate a personalized email subject line for {name}"
@@ -177,7 +310,6 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
177
  />
178
  <div className="text-xs text-muted-foreground">
179
  <p>Enter a prompt for Gemini AI.</p>
180
- <p className="mt-1">Use variables: {"{name}, {category}, {notes}, {website}, {address}"}</p>
181
  </div>
182
  </div>
183
  </div>
@@ -206,12 +338,18 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
206
  </div>
207
  <div className="col-span-3 space-y-2">
208
  <Label htmlFor="url">URL</Label>
209
- <Input
210
- id="url"
211
- placeholder="https://api.example.com/v1/resource"
212
- value={config?.url || ""}
213
- onChange={(e) => setConfig({ ...config, url: e.target.value })}
214
- />
 
 
 
 
 
 
215
  </div>
216
  </div>
217
  <div className="space-y-2">
@@ -227,7 +365,13 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
227
  </div>
228
  {(config?.method === "POST" || config?.method === "PUT") && (
229
  <div className="space-y-2">
230
- <Label htmlFor="body">Body (JSON)</Label>
 
 
 
 
 
 
231
  <Textarea
232
  id="body"
233
  placeholder='{ "key": "value" }'
@@ -269,10 +413,18 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
269
  </div>
270
  </div>
271
  );
 
 
272
  return (
273
  <div className="space-y-4">
274
  <div className="space-y-2">
275
- <Label htmlFor="agentPrompt">Agent Instructions</Label>
 
 
 
 
 
 
276
  <Textarea
277
  id="agentPrompt"
278
  placeholder="Analyze the rows in the excel sheet and extract..."
@@ -337,28 +489,18 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
337
  </div>
338
  </div>
339
  );
340
- case "merge":
341
- return (
342
- <div className="space-y-4">
343
- <p className="text-sm text-muted-foreground">
344
- Merges multiple input branches into a single output. No configuration needed usually.
345
- </p>
346
- </div>
347
- );
348
- case "splitInBatches":
349
- return (
350
- <div className="space-y-4">
351
- <p className="text-sm text-muted-foreground">
352
- loops through an array of items from the previous node. The &apos;Done&apos; output triggers when finished.
353
- </p>
354
- {/* Could add batch size config here later */}
355
- </div>
356
- );
357
  case "filter":
358
  return (
359
  <div className="space-y-4">
360
  <div className="space-y-2">
361
- <Label htmlFor="filterCondition">Filter Condition</Label>
 
 
 
 
 
 
362
  <Input
363
  id="filterCondition"
364
  placeholder="item.price > 100"
@@ -405,12 +547,13 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
405
  <Label htmlFor="scraperAction">Scraper Action</Label>
406
  <Select
407
  value={config?.scraperAction || "extract-emails"}
408
- onValueChange={(value) => setConfig({ ...config, scraperAction: value as "summarize" | "extract-emails" | "clean-html" | "markdown" })}
409
  >
410
  <SelectTrigger>
411
  <SelectValue placeholder="Select Action" />
412
  </SelectTrigger>
413
  <SelectContent>
 
414
  <SelectItem value="extract-emails">Extract Emails</SelectItem>
415
  <SelectItem value="summarize">Summarize Content</SelectItem>
416
  <SelectItem value="clean-html">Clean HTML / Remove Tags</SelectItem>
@@ -419,15 +562,21 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
419
  </Select>
420
  </div>
421
  <div className="space-y-2">
422
- <Label htmlFor="scraperInputField">Input Variable</Label>
 
 
 
 
 
 
423
  <Input
424
  id="scraperInputField"
425
- placeholder="{scrapedContent}"
426
  value={config?.scraperInputField || ""}
427
  onChange={(e) => setConfig({ ...config, scraperInputField: e.target.value })}
428
  />
429
  <p className="text-xs text-muted-foreground">
430
- The variable containing the raw text or HTML to process.
431
  </p>
432
  </div>
433
  </div>
@@ -467,20 +616,52 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
467
  />
468
  </div>
469
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  {renderConfigForm()}
471
  </TabsContent>
472
 
473
  <TabsContent value="test" className="mt-0 space-y-4">
474
  <div className="space-y-2">
475
- <Label>Test Input (JSON Context)</Label>
 
 
 
 
 
 
 
 
 
476
  <Textarea
477
  className="font-mono text-xs"
478
- rows={5}
479
- placeholder='{ "email": "test@example.com", "price": 150 }'
480
  value={testInput}
481
  onChange={(e) => setTestInput(e.target.value)}
482
  />
483
- <p className="text-xs text-muted-foreground">Mock the variables available to this node.</p>
484
  </div>
485
 
486
  <Button size="sm" onClick={async () => {
@@ -518,13 +699,21 @@ export function NodeConfigDialog({ open, onOpenChange, node, onSave }: NodeConfi
518
  </ScrollArea>
519
  </Tabs>
520
 
521
- <DialogFooter className="mr-6 mb-4">
522
- <Button variant="outline" onClick={() => onOpenChange(false)}>
523
- Cancel
524
- </Button>
525
- <Button onClick={handleSave}>
526
- Save Configuration
527
- </Button>
 
 
 
 
 
 
 
 
528
  </DialogFooter>
529
  </DialogContent>
530
  </Dialog>
 
1
  import React, { useState, useEffect } from "react";
2
  import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
3
  import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
4
+ import {
5
+ DropdownMenu,
6
+ DropdownMenuContent,
7
+ DropdownMenuItem,
8
+ DropdownMenuTrigger,
9
+ } from "@/components/ui/dropdown-menu";
10
  import { ScrollArea } from "@/components/ui/scroll-area";
11
+ import { Play, Loader2, Info, Plus, Trash2 } from "lucide-react";
12
  import { Button } from "@/components/ui/button";
13
  import { Input } from "@/components/ui/input";
14
  import { Label } from "@/components/ui/label";
 
23
  onOpenChange: (open: boolean) => void;
24
  node: Node<NodeData>;
25
  onSave: (config: NodeData["config"], label?: string) => void;
26
+ onDelete?: () => void;
27
  }
28
 
29
  interface EmailTemplate {
 
31
  name: string;
32
  }
33
 
34
+ const ALL_VARIABLES = [
35
+ "business.name",
36
+ "business.email",
37
+ "business.website",
38
+ "business.phone",
39
+ "business.address",
40
+ "business.rating",
41
+ "business.reviewCount",
42
+ "variables.aiResult",
43
+ "variables.apiResponse",
44
+ "variables.scrapedData",
45
+ "variables.customVar"
46
+ ];
47
+
48
+ const NODE_HELPERS = {
49
+ start: {
50
+ description: "The starting point of your workflow.",
51
+ variables: []
52
+ },
53
+ condition: {
54
+ description: "Checks a condition to decide which path to take (True/False).",
55
+ variables: ALL_VARIABLES
56
+ },
57
+ template: {
58
+ description: "Sends an automated email using a selected template.",
59
+ variables: ALL_VARIABLES
60
+ },
61
+ delay: {
62
+ description: "Pauses the workflow for a specified duration.",
63
+ variables: []
64
+ },
65
+ custom: {
66
+ description: "Executes custom JavaScript code for advanced logic.",
67
+ variables: ["business", "variables", "context"]
68
+ },
69
+ gemini: {
70
+ description: "Uses AI to generate text. Result saved to {variables.aiResult}.",
71
+ variables: ALL_VARIABLES
72
+ },
73
+ apiRequest: {
74
+ description: "Makes an HTTP request. Response saved to {variables.apiResponse}.",
75
+ variables: ALL_VARIABLES
76
+ },
77
+ webhook: {
78
+ description: "Triggers the workflow via an external HTTP call.",
79
+ variables: ["query", "body"]
80
+ },
81
+ schedule: {
82
+ description: "Runs the workflow repeatedly on a cron schedule.",
83
+ variables: []
84
+ },
85
+ scraper: {
86
+ description: "Processes scraped data. Result saved to {variables.scrapedData}.",
87
+ variables: ALL_VARIABLES
88
+ },
89
+ filter: {
90
+ description: "Filters items based on a condition; stops execution if false.",
91
+ variables: ["item", ...ALL_VARIABLES]
92
+ },
93
+ set: {
94
+ description: "Defines or updates variables in the workflow context.",
95
+ variables: []
96
+ },
97
+ merge: {
98
+ description: "Merges multiple execution branches back into one.",
99
+ variables: []
100
+ },
101
+ splitInBatches: {
102
+ description: "Iterates over a list of items (Loop).",
103
+ variables: ["items"]
104
+ },
105
+ agent: {
106
+ description: "AI Agent that processes Excel/CSV data. Result saved to {variables.aiResult}.",
107
+ variables: ["row", "context", ...ALL_VARIABLES]
108
+ }
109
+ };
110
+
111
+ export function NodeConfigDialog({ open, onOpenChange, node, onSave, onDelete }: NodeConfigDialogProps) {
112
  const [config, setConfig] = useState<NodeData["config"]>(node.data.config || {});
113
  const [label, setLabel] = useState(node.data.label);
114
  const [templates, setTemplates] = useState<EmailTemplate[]>([]);
 
119
 
120
  const { get, loading: loadingTemplates } = useApi<{ templates: EmailTemplate[] }>();
121
 
122
+ // Reset state when node changes or dialog opens
123
+ useEffect(() => {
124
+ if (open) {
125
+ setConfig(node.data.config || {});
126
+ setLabel(node.data.label);
127
+ setTestInput(JSON.stringify(node.data.config || {}, null, 2));
128
+ setTestOutput("");
129
+ }
130
+ }, [open, node.id, node.data.config, node.data.label]);
131
+
132
  // Fetch templates when dialog opens and node is template type
133
  useEffect(() => {
134
  if (open && node.data.type === "template") {
 
153
  onSave(config, label);
154
  };
155
 
156
+ const VariableInsert = ({ onInsert, variables }: { onInsert: (v: string) => void, variables: string[] }) => {
157
+ if (!variables || variables.length === 0) return null;
158
+ return (
159
+ <DropdownMenu>
160
+ <DropdownMenuTrigger asChild>
161
+ <Button variant="ghost" size="sm" className="h-6 gap-1 text-xs ml-auto">
162
+ <Plus className="h-3 w-3" /> Insert Variable
163
+ </Button>
164
+ </DropdownMenuTrigger>
165
+ <DropdownMenuContent align="end">
166
+ {variables.map((v) => (
167
+ <DropdownMenuItem key={v} onClick={() => onInsert(`{${v}}`)}>
168
+ {`{${v}}`}
169
+ </DropdownMenuItem>
170
+ ))}
171
+ </DropdownMenuContent>
172
+ </DropdownMenu>
173
+ );
174
+ };
175
+
176
  const renderConfigForm = () => {
177
+ const nodeHelpers = NODE_HELPERS[node.data.type as keyof typeof NODE_HELPERS];
178
+ const variables = nodeHelpers?.variables || [];
179
+
180
  switch (node.data.type) {
181
  case "start":
182
  return (
 
191
  return (
192
  <div className="space-y-4">
193
  <div className="space-y-2">
194
+ <div className="flex justify-between items-center">
195
+ <Label htmlFor="condition">Condition Expression</Label>
196
+ <VariableInsert
197
+ variables={variables}
198
+ onInsert={(v) => setConfig({ ...config, condition: (config?.condition || "") + v })}
199
+ />
200
+ </div>
201
  <Input
202
  id="condition"
203
  placeholder="!website"
 
206
  />
207
  <div className="text-xs text-muted-foreground">
208
  <p>Examples: !website (no website), email (has email), category == &quot;restaurant&quot;</p>
 
209
  </div>
210
  </div>
211
  </div>
 
268
  return (
269
  <div className="space-y-4">
270
  <div className="space-y-2">
271
+ <div className="flex justify-between items-center">
272
+ <Label htmlFor="customCode">Custom Function Code</Label>
273
+ <VariableInsert
274
+ variables={variables}
275
+ onInsert={(v) => setConfig({ ...config, customCode: (config?.customCode || "") + v })}
276
+ />
277
+ </div>
278
  <Textarea
279
  id="customCode"
280
  placeholder="// JavaScript code here&#10;return true;"
 
285
  />
286
  <div className="text-xs text-muted-foreground">
287
  <p>Write custom JavaScript code. Return true to continue, false to stop.</p>
 
288
  </div>
289
  </div>
290
  </div>
 
294
  return (
295
  <div className="space-y-4">
296
  <div className="space-y-2">
297
+ <div className="flex justify-between items-center">
298
+ <Label htmlFor="aiPrompt">AI Prompt</Label>
299
+ <VariableInsert
300
+ variables={variables}
301
+ onInsert={(v) => setConfig({ ...config, aiPrompt: (config?.aiPrompt || "") + v })}
302
+ />
303
+ </div>
304
  <Textarea
305
  id="aiPrompt"
306
  placeholder="Generate a personalized email subject line for {name}"
 
310
  />
311
  <div className="text-xs text-muted-foreground">
312
  <p>Enter a prompt for Gemini AI.</p>
 
313
  </div>
314
  </div>
315
  </div>
 
338
  </div>
339
  <div className="col-span-3 space-y-2">
340
  <Label htmlFor="url">URL</Label>
341
+ <div className="flex gap-2">
342
+ <Input
343
+ id="url"
344
+ placeholder="https://api.example.com/v1/resource"
345
+ value={config?.url || ""}
346
+ onChange={(e) => setConfig({ ...config, url: e.target.value })}
347
+ />
348
+ <VariableInsert
349
+ variables={variables}
350
+ onInsert={(v) => setConfig({ ...config, url: (config?.url || "") + v })}
351
+ />
352
+ </div>
353
  </div>
354
  </div>
355
  <div className="space-y-2">
 
365
  </div>
366
  {(config?.method === "POST" || config?.method === "PUT") && (
367
  <div className="space-y-2">
368
+ <div className="flex justify-between items-center">
369
+ <Label htmlFor="body">Body (JSON)</Label>
370
+ <VariableInsert
371
+ variables={variables}
372
+ onInsert={(v) => setConfig({ ...config, body: (config?.body || "") + v })}
373
+ />
374
+ </div>
375
  <Textarea
376
  id="body"
377
  placeholder='{ "key": "value" }'
 
413
  </div>
414
  </div>
415
  );
416
+
417
+ case "agent": // Fixed duplicated case structure
418
  return (
419
  <div className="space-y-4">
420
  <div className="space-y-2">
421
+ <div className="flex justify-between items-center">
422
+ <Label htmlFor="agentPrompt">Agent Instructions</Label>
423
+ <VariableInsert
424
+ variables={variables}
425
+ onInsert={(v) => setConfig({ ...config, agentPrompt: (config?.agentPrompt || "") + v })}
426
+ />
427
+ </div>
428
  <Textarea
429
  id="agentPrompt"
430
  placeholder="Analyze the rows in the excel sheet and extract..."
 
489
  </div>
490
  </div>
491
  );
492
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  case "filter":
494
  return (
495
  <div className="space-y-4">
496
  <div className="space-y-2">
497
+ <div className="flex justify-between items-center">
498
+ <Label htmlFor="filterCondition">Filter Condition</Label>
499
+ <VariableInsert
500
+ variables={variables}
501
+ onInsert={(v) => setConfig({ ...config, filterCondition: (config?.filterCondition || "") + v })}
502
+ />
503
+ </div>
504
  <Input
505
  id="filterCondition"
506
  placeholder="item.price > 100"
 
547
  <Label htmlFor="scraperAction">Scraper Action</Label>
548
  <Select
549
  value={config?.scraperAction || "extract-emails"}
550
+ onValueChange={(value) => setConfig({ ...config, scraperAction: value as "fetch-url" | "summarize" | "extract-emails" | "clean-html" | "markdown" })}
551
  >
552
  <SelectTrigger>
553
  <SelectValue placeholder="Select Action" />
554
  </SelectTrigger>
555
  <SelectContent>
556
+ <SelectItem value="fetch-url">Fetch URL Content</SelectItem>
557
  <SelectItem value="extract-emails">Extract Emails</SelectItem>
558
  <SelectItem value="summarize">Summarize Content</SelectItem>
559
  <SelectItem value="clean-html">Clean HTML / Remove Tags</SelectItem>
 
562
  </Select>
563
  </div>
564
  <div className="space-y-2">
565
+ <div className="flex justify-between items-center">
566
+ <Label htmlFor="scraperInputField">Input Variable (or URL)</Label>
567
+ <VariableInsert
568
+ variables={variables}
569
+ onInsert={(v) => setConfig({ ...config, scraperInputField: (config?.scraperInputField || "") + v })}
570
+ />
571
+ </div>
572
  <Input
573
  id="scraperInputField"
574
+ placeholder="{scrapedContent} or {business.website}"
575
  value={config?.scraperInputField || ""}
576
  onChange={(e) => setConfig({ ...config, scraperInputField: e.target.value })}
577
  />
578
  <p className="text-xs text-muted-foreground">
579
+ The variable or URL to process. Use <code>{`{business.website}`}</code> for fetching.
580
  </p>
581
  </div>
582
  </div>
 
616
  />
617
  </div>
618
 
619
+ {/* Helper Section */}
620
+ <div className="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg border border-blue-100 dark:border-blue-800">
621
+ <h4 className="text-sm font-semibold flex items-center gap-2 mb-1">
622
+ <Info className="h-4 w-4 text-blue-500" />
623
+ How to use this node
624
+ </h4>
625
+ <p className="text-sm text-muted-foreground mb-2">
626
+ {NODE_HELPERS[node.data.type as keyof typeof NODE_HELPERS]?.description || "Configure this node's settings below."}
627
+ </p>
628
+ {NODE_HELPERS[node.data.type as keyof typeof NODE_HELPERS]?.variables?.length > 0 && (
629
+ <div className="text-xs">
630
+ <span className="font-medium text-blue-600 dark:text-blue-400">Available Variables:</span>
631
+ <div className="flex flex-wrap gap-1 mt-1">
632
+ {NODE_HELPERS[node.data.type as keyof typeof NODE_HELPERS]?.variables.map((v) => (
633
+ <code key={v} className="bg-background px-1.5 py-0.5 rounded border text-muted-foreground">
634
+ {`{${v}}`}
635
+ </code>
636
+ ))}
637
+ </div>
638
+ </div>
639
+ )}
640
+ </div>
641
+
642
  {renderConfigForm()}
643
  </TabsContent>
644
 
645
  <TabsContent value="test" className="mt-0 space-y-4">
646
  <div className="space-y-2">
647
+ <div className="flex justify-between items-center">
648
+ <Label>Test Input (JSON Context)</Label>
649
+ <VariableInsert
650
+ variables={NODE_HELPERS[node.data.type as keyof typeof NODE_HELPERS]?.variables || []}
651
+ onInsert={(v) => setTestInput((prev) => {
652
+ // Try to insert cleanly if possible, otherwise append
653
+ return prev + ` "${v}"`;
654
+ })}
655
+ />
656
+ </div>
657
  <Textarea
658
  className="font-mono text-xs"
659
+ rows={8}
660
+ placeholder={'{\n "business": {\n "name": "Test Business",\n "website": "example.com",\n "email": "test@example.com"\n },\n "variables": {\n "scrapedData": "<html>...</html>"\n }\n}'}
661
  value={testInput}
662
  onChange={(e) => setTestInput(e.target.value)}
663
  />
664
+ <p className="text-xs text-muted-foreground">Mock the variables available to this node. Defines <code>business</code>, <code>variables</code> etc.</p>
665
  </div>
666
 
667
  <Button size="sm" onClick={async () => {
 
699
  </ScrollArea>
700
  </Tabs>
701
 
702
+ <DialogFooter className="mr-6 mb-4 flex justify-between sm:justify-between">
703
+ {onDelete && (
704
+ <Button variant="destructive" onClick={onDelete} className="mr-auto">
705
+ <Trash2 className="h-4 w-4 mr-2" />
706
+ Delete Node
707
+ </Button>
708
+ )}
709
+ <div className="flex gap-2">
710
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
711
+ Cancel
712
+ </Button>
713
+ <Button onClick={handleSave}>
714
+ Save Configuration
715
+ </Button>
716
+ </div>
717
  </DialogFooter>
718
  </DialogContent>
719
  </Dialog>
components/node-editor/node-editor.tsx CHANGED
@@ -14,11 +14,12 @@ import ReactFlow, {
14
  BackgroundVariant,
15
  NodeMouseHandler,
16
  SelectionMode,
 
17
  } from "reactflow";
18
  import "reactflow/dist/style.css";
19
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
20
  import { Button } from "@/components/ui/button";
21
- import { Plus, Save, Play, Copy, Trash2, Undo, Redo, FileText, Loader2, Download, Upload, HelpCircle, BookOpen, Hand, MousePointer2, Sparkles } from "lucide-react";
22
  import { AiWorkflowDialog } from "./ai-workflow-dialog";
23
  import { ImportWorkflowDialog } from "./import-workflow-dialog";
24
  import { NodeConfigDialog } from "./node-config-dialog";
@@ -56,7 +57,7 @@ export interface NodeData {
56
  filterCondition?: string;
57
  setVariables?: Record<string, string>;
58
  // Scraper config
59
- scraperAction?: "summarize" | "extract-emails" | "clean-html" | "markdown";
60
  scraperInputField?: string;
61
  };
62
  isConnected?: boolean;
@@ -114,10 +115,13 @@ export function NodeEditor({
114
  const [contextMenu, setContextMenu] = useState<{
115
  x: number;
116
  y: number;
117
- nodeId: string;
 
118
  } | null>(null);
119
  const [isExecuting, setIsExecuting] = useState(false);
120
  const [copiedNodes, setCopiedNodes] = useState<Node<NodeData>[]>([]);
 
 
121
  const [canvasMode, setCanvasMode] = useState<'drag' | 'select'>('drag');
122
  const { toast } = useToast();
123
 
@@ -258,12 +262,14 @@ export function NodeEditor({
258
  [setEdges, saveToHistory]
259
  );
260
 
 
 
261
  const addNode = useCallback(
262
  (type: NodeData["type"]) => {
263
  const nodeLabels = {
264
  start: "Start",
265
  condition: "Condition",
266
- template: "Email Template",
267
  delay: "Delay",
268
  custom: "Custom Function",
269
  gemini: "AI Task",
@@ -278,6 +284,21 @@ export function NodeEditor({
278
  scraper: "Scraper Action",
279
  };
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  const newNode: Node<NodeData> = {
282
  id: `${type}-${Date.now()}`,
283
  type: "workflowNode",
@@ -286,16 +307,13 @@ export function NodeEditor({
286
  type,
287
  config: {},
288
  },
289
- position: {
290
- x: Math.random() * 400 + 100,
291
- y: Math.random() * 400 + 100,
292
- },
293
  };
294
 
295
  setNodes((nds) => [...nds, newNode]);
296
  saveToHistory();
297
  },
298
- [setNodes, saveToHistory]
299
  );
300
 
301
  const onNodeClick = useCallback((event: React.MouseEvent, node: Node<NodeData>) => {
@@ -313,6 +331,18 @@ export function NodeEditor({
313
  });
314
  }, []);
315
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  const onPaneContextMenu = useCallback((event: React.MouseEvent) => {
317
  event.preventDefault();
318
  setContextMenu({
@@ -322,12 +352,18 @@ export function NodeEditor({
322
  });
323
  }, []);
324
 
325
- const handleDeleteNode = useCallback(() => {
326
  if (!contextMenu) return;
327
- setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId));
328
- setEdges((eds) =>
329
- eds.filter((e) => e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId)
330
- );
 
 
 
 
 
 
331
  setContextMenu(null);
332
  saveToHistory();
333
  }, [contextMenu, setNodes, setEdges, saveToHistory]);
@@ -411,19 +447,20 @@ export function NodeEditor({
411
 
412
  const data = await response.json();
413
 
 
 
 
414
  if (data.success) {
415
  toast({
416
  title: "Execution Completed",
417
- description: `Processed ${data.totalProcessed} businesses.`,
418
  });
419
- console.log("Execution Logs:", data.logs);
420
  } else {
421
  toast({
422
  title: "Execution Failed",
423
- description: "Check console for logs.",
424
  variant: "destructive",
425
  });
426
- console.error("Execution Logs:", data.logs);
427
  }
428
  } catch (error) {
429
  toast({
@@ -485,7 +522,19 @@ export function NodeEditor({
485
  </TooltipTrigger>
486
  <TooltipContent>Generate workflow with AI</TooltipContent>
487
  </Tooltip>
 
 
 
 
 
 
488
 
 
 
 
 
 
 
489
  <div className="w-px h-6 bg-border mx-1 self-center" />
490
 
491
  <Tooltip>
@@ -698,11 +747,13 @@ export function NodeEditor({
698
  nodes={nodes}
699
  edges={edges}
700
 
 
701
  onNodesChange={onNodesChange}
702
  onEdgesChange={onEdgesChange}
703
  onConnect={onConnect}
704
  onNodeClick={onNodeClick}
705
  onNodeContextMenu={onNodeContextMenu}
 
706
  onPaneContextMenu={onPaneContextMenu}
707
  nodeTypes={nodeTypes}
708
  fitView
@@ -751,13 +802,21 @@ export function NodeEditor({
751
  Duplicate Node
752
  </button>
753
  <button
754
- onClick={handleDeleteNode}
755
  className="w-full px-4 py-2 text-left hover:bg-destructive/10 text-destructive flex items-center gap-2 text-sm"
756
  >
757
  <Trash2 className="h-4 w-4" />
758
  Delete Node
759
  </button>
760
  </>
 
 
 
 
 
 
 
 
761
  ) : (
762
  <>
763
  <button
@@ -787,6 +846,13 @@ export function NodeEditor({
787
  updateNodeConfig(selectedNode.id, config, label);
788
  setIsConfigOpen(false);
789
  }}
 
 
 
 
 
 
 
790
  />
791
  )}
792
 
@@ -812,6 +878,64 @@ export function NodeEditor({
812
  onOpenChange={setIsAiDialogOpen}
813
  onGenerate={handleAiGenerate}
814
  />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
815
  </div>
816
  </TooltipProvider>
817
  );
 
14
  BackgroundVariant,
15
  NodeMouseHandler,
16
  SelectionMode,
17
+ ReactFlowInstance,
18
  } from "reactflow";
19
  import "reactflow/dist/style.css";
20
  import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
21
  import { Button } from "@/components/ui/button";
22
+ import { Plus, Save, Play, Copy, Trash2, Undo, Redo, FileText, Loader2, Download, Upload, HelpCircle, BookOpen, Hand, MousePointer2, Sparkles, TerminalIcon, X } from "lucide-react";
23
  import { AiWorkflowDialog } from "./ai-workflow-dialog";
24
  import { ImportWorkflowDialog } from "./import-workflow-dialog";
25
  import { NodeConfigDialog } from "./node-config-dialog";
 
57
  filterCondition?: string;
58
  setVariables?: Record<string, string>;
59
  // Scraper config
60
+ scraperAction?: "summarize" | "extract-emails" | "clean-html" | "markdown" | "fetch-url";
61
  scraperInputField?: string;
62
  };
63
  isConnected?: boolean;
 
115
  const [contextMenu, setContextMenu] = useState<{
116
  x: number;
117
  y: number;
118
+ nodeId?: string;
119
+ edgeId?: string;
120
  } | null>(null);
121
  const [isExecuting, setIsExecuting] = useState(false);
122
  const [copiedNodes, setCopiedNodes] = useState<Node<NodeData>[]>([]);
123
+ const [executionLogs, setExecutionLogs] = useState<string[]>([]);
124
+ const [showTerminal, setShowTerminal] = useState(false);
125
  const [canvasMode, setCanvasMode] = useState<'drag' | 'select'>('drag');
126
  const { toast } = useToast();
127
 
 
262
  [setEdges, saveToHistory]
263
  );
264
 
265
+ const [rfInstance, setRfInstance] = useState<ReactFlowInstance | null>(null);
266
+
267
  const addNode = useCallback(
268
  (type: NodeData["type"]) => {
269
  const nodeLabels = {
270
  start: "Start",
271
  condition: "Condition",
272
+ template: "Send Email",
273
  delay: "Delay",
274
  custom: "Custom Function",
275
  gemini: "AI Task",
 
284
  scraper: "Scraper Action",
285
  };
286
 
287
+ // Calculate center position
288
+ let position = { x: 250, y: 250 };
289
+ if (rfInstance) {
290
+ const { x, y, zoom } = rfInstance.getViewport();
291
+ // Assuming a container width/height or using window as approximation if unknown,
292
+ // but reactflow usually fills parent.
293
+ // A safer bet is just center of the viewport - translation
294
+ // Viewport x/y is the transformation.
295
+ // Center X in flow = (-viewportX + containerHalfWidth) / zoom
296
+ // We'll approximate container as 1000x800 if not easily accessible, or use window/2
297
+ const centerX = (-x + (window.innerWidth / 2)) / zoom;
298
+ const centerY = (-y + (window.innerHeight / 2)) / zoom;
299
+ position = { x: centerX - 100 + (Math.random() * 50), y: centerY - 50 + (Math.random() * 50) };
300
+ }
301
+
302
  const newNode: Node<NodeData> = {
303
  id: `${type}-${Date.now()}`,
304
  type: "workflowNode",
 
307
  type,
308
  config: {},
309
  },
310
+ position,
 
 
 
311
  };
312
 
313
  setNodes((nds) => [...nds, newNode]);
314
  saveToHistory();
315
  },
316
+ [setNodes, saveToHistory, rfInstance]
317
  );
318
 
319
  const onNodeClick = useCallback((event: React.MouseEvent, node: Node<NodeData>) => {
 
331
  });
332
  }, []);
333
 
334
+ const onEdgeContextMenu = useCallback(
335
+ (event: React.MouseEvent, edge: Edge) => {
336
+ event.preventDefault();
337
+ setContextMenu({
338
+ x: event.clientX,
339
+ y: event.clientY,
340
+ edgeId: edge.id,
341
+ });
342
+ },
343
+ []
344
+ );
345
+
346
  const onPaneContextMenu = useCallback((event: React.MouseEvent) => {
347
  event.preventDefault();
348
  setContextMenu({
 
352
  });
353
  }, []);
354
 
355
+ const handleDeleteItem = useCallback(() => {
356
  if (!contextMenu) return;
357
+
358
+ if (contextMenu.nodeId) {
359
+ setNodes((nds) => nds.filter((n) => n.id !== contextMenu.nodeId));
360
+ setEdges((eds) =>
361
+ eds.filter((e) => e.source !== contextMenu.nodeId && e.target !== contextMenu.nodeId)
362
+ );
363
+ } else if (contextMenu.edgeId) {
364
+ setEdges((eds) => eds.filter((e) => e.id !== contextMenu.edgeId));
365
+ }
366
+
367
  setContextMenu(null);
368
  saveToHistory();
369
  }, [contextMenu, setNodes, setEdges, saveToHistory]);
 
447
 
448
  const data = await response.json();
449
 
450
+ setExecutionLogs(data.logs || []);
451
+ setShowTerminal(true);
452
+
453
  if (data.success) {
454
  toast({
455
  title: "Execution Completed",
456
+ description: `Processed ${data.totalProcessed} businesses. Check terminal for details.`,
457
  });
 
458
  } else {
459
  toast({
460
  title: "Execution Failed",
461
+ description: "Check terminal for logs.",
462
  variant: "destructive",
463
  });
 
464
  }
465
  } catch (error) {
466
  toast({
 
522
  </TooltipTrigger>
523
  <TooltipContent>Generate workflow with AI</TooltipContent>
524
  </Tooltip>
525
+ <Tooltip>
526
+ <TooltipTrigger asChild>
527
+ <Button
528
+ onClick={() => setShowTerminal(true)}
529
+ size="icon"
530
+ variant="ghost"
531
 
532
+ >
533
+ <TerminalIcon className="h-4 w-4" />
534
+ </Button>
535
+ </TooltipTrigger>
536
+ <TooltipContent>Terminal</TooltipContent>
537
+ </Tooltip>
538
  <div className="w-px h-6 bg-border mx-1 self-center" />
539
 
540
  <Tooltip>
 
747
  nodes={nodes}
748
  edges={edges}
749
 
750
+ onInit={setRfInstance}
751
  onNodesChange={onNodesChange}
752
  onEdgesChange={onEdgesChange}
753
  onConnect={onConnect}
754
  onNodeClick={onNodeClick}
755
  onNodeContextMenu={onNodeContextMenu}
756
+ onEdgeContextMenu={onEdgeContextMenu}
757
  onPaneContextMenu={onPaneContextMenu}
758
  nodeTypes={nodeTypes}
759
  fitView
 
802
  Duplicate Node
803
  </button>
804
  <button
805
+ onClick={handleDeleteItem}
806
  className="w-full px-4 py-2 text-left hover:bg-destructive/10 text-destructive flex items-center gap-2 text-sm"
807
  >
808
  <Trash2 className="h-4 w-4" />
809
  Delete Node
810
  </button>
811
  </>
812
+ ) : contextMenu.edgeId ? (
813
+ <button
814
+ onClick={handleDeleteItem}
815
+ className="w-full px-4 py-2 text-left hover:bg-destructive/10 text-destructive flex items-center gap-2 text-sm"
816
+ >
817
+ <Trash2 className="h-4 w-4" />
818
+ Delete Connection
819
+ </button>
820
  ) : (
821
  <>
822
  <button
 
846
  updateNodeConfig(selectedNode.id, config, label);
847
  setIsConfigOpen(false);
848
  }}
849
+ onDelete={() => {
850
+ setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
851
+ setEdges((eds) => eds.filter((e) => e.source !== selectedNode.id && e.target !== selectedNode.id));
852
+ setSelectedNode(null);
853
+ setIsConfigOpen(false);
854
+ saveToHistory();
855
+ }}
856
  />
857
  )}
858
 
 
878
  onOpenChange={setIsAiDialogOpen}
879
  onGenerate={handleAiGenerate}
880
  />
881
+ {/* Terminal Panel */}
882
+ {showTerminal && (
883
+ <div className="fixed bottom-0 left-0 right-0 h-72 bg-slate-950 border-t border-slate-800 shadow-2xl flex flex-col z-200 animate-in slide-in-from-bottom duration-300">
884
+ <div className="flex items-center justify-between px-4 py-3 bg-slate-900 border-b border-slate-800">
885
+ <div className="flex items-center gap-3 text-slate-200">
886
+ <TerminalIcon className="h-5 w-5 text-indigo-400" />
887
+ <span className="text-sm font-mono font-bold tracking-tight">SYSTEM TERMINAL</span>
888
+ <span className="text-xs px-2 py-0.5 rounded-full bg-slate-800 text-slate-400 border border-slate-700">
889
+ {executionLogs.length} Lines
890
+ </span>
891
+ </div>
892
+ <div className="flex items-center gap-2">
893
+ <Button
894
+ variant="ghost"
895
+ size="sm"
896
+ className="h-8 w-8 text-slate-400 hover:text-white hover:bg-slate-800 rounded-md transition-colors"
897
+ onClick={() => setExecutionLogs([])}
898
+ title="Clear Logs"
899
+ >
900
+ <Trash2 className="h-4 w-4" />
901
+ </Button>
902
+ <Button
903
+ variant="ghost"
904
+ size="sm"
905
+ className="h-8 w-8 text-slate-400 hover:text-white hover:bg-slate-800 rounded-md transition-colors"
906
+ onClick={() => setShowTerminal(false)}
907
+ title="Close Terminal"
908
+ >
909
+ <X className="h-4 w-4" />
910
+ </Button>
911
+ </div>
912
+ </div>
913
+ <div className="flex-1 overflow-auto p-4 font-mono text-xs md:text-sm text-slate-300 space-y-1.5 scrollbar-thin scrollbar-thumb-slate-700 scrollbar-track-transparent">
914
+ {executionLogs.length === 0 ? (
915
+ <div className="h-full flex flex-col items-center justify-center text-slate-600 gap-2">
916
+ <TerminalIcon className="h-8 w-8 opacity-20" />
917
+ <p>Waiting for execution...</p>
918
+ </div>
919
+ ) : (
920
+ executionLogs.map((log, i) => (
921
+ <div key={i} className="flex gap-3 border-b border-slate-900/50 pb-1 last:border-0 font-medium">
922
+ <span className="text-slate-600 select-none min-w-[24px] text-right">{(i + 1).toString().padStart(2, '0')}</span>
923
+ <span className={
924
+ log.toLowerCase().includes("error") ? "text-red-400 bg-red-950/20 px-1 rounded" :
925
+ log.toLowerCase().includes("warning") ? "text-amber-400 bg-amber-950/20 px-1 rounded" :
926
+ log.toLowerCase().includes("success") || log.toLowerCase().includes("completed") ? "text-emerald-400" :
927
+ log.toLowerCase().includes("sending") || log.toLowerCase().includes("running") ? "text-blue-400" :
928
+ log.toLowerCase().includes("response") ? "text-cyan-400" :
929
+ "text-slate-300"
930
+ }>
931
+ {log}
932
+ </span>
933
+ </div>
934
+ ))
935
+ )}
936
+ </div>
937
+ </div>
938
+ )}
939
  </div>
940
  </TooltipProvider>
941
  );
components/node-editor/workflow-node.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { memo } from "react";
2
  import { Handle, Position, NodeProps } from "reactflow";
3
  import { Settings } from "lucide-react";
4
  import { NodeData } from "./node-editor";
@@ -79,7 +79,30 @@ export const WorkflowNode = memo(({ data, selected }: NodeProps<NodeData>) => {
79
  </div>
80
  </div>
81
 
82
- {true && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  <Handle
84
  type="source"
85
  position={Position.Bottom}
 
1
+ import { memo } from "react";
2
  import { Handle, Position, NodeProps } from "reactflow";
3
  import { Settings } from "lucide-react";
4
  import { NodeData } from "./node-editor";
 
79
  </div>
80
  </div>
81
 
82
+ {data.type === "condition" ? (
83
+ <div className="flex justify-between w-full mt-2 relative">
84
+ <div className="relative">
85
+ <Handle
86
+ type="source"
87
+ id="true"
88
+ position={Position.Bottom}
89
+ className="w-3 h-3 left-2"
90
+ style={{ background: "#22c55e" }}
91
+ />
92
+ <span className="text-[10px] text-green-600 absolute top-3 left-0 font-bold">True</span>
93
+ </div>
94
+ <div className="relative">
95
+ <Handle
96
+ type="source"
97
+ id="false"
98
+ position={Position.Bottom}
99
+ className="w-3 h-3 left-auto right-2"
100
+ style={{ background: "#ef4444" }}
101
+ />
102
+ <span className="text-[10px] text-red-600 absolute top-3 right-0 font-bold">False</span>
103
+ </div>
104
+ </div>
105
+ ) : (
106
  <Handle
107
  type="source"
108
  position={Position.Bottom}
components/node-editor/workflow-templates-dialog.tsx CHANGED
@@ -14,6 +14,30 @@ interface WorkflowTemplate {
14
  }
15
 
16
  const WORKFLOW_TEMPLATES: WorkflowTemplate[] = [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  {
18
  id: "simple-follow-up",
19
  name: "Simple Follow-up",
 
14
  }
15
 
16
  const WORKFLOW_TEMPLATES: WorkflowTemplate[] = [
17
+ {
18
+ id: "scraped-email-automation",
19
+ name: "Auto Email from Scraper",
20
+ description: "Process scraped data, cleaner info, check for website, and send specific email.",
21
+ nodes: [
22
+ { id: "1", type: "workflowNode", data: { label: "Start", type: "start", config: {} }, position: { x: 300, y: 50 }, },
23
+ { id: "cond", type: "workflowNode", data: { label: "Has Website?", type: "condition", config: { condition: "business.website" } }, position: { x: 300, y: 150 }, },
24
+ // Yes Path: Fetch -> Extract -> Send
25
+ { id: "fetch", type: "workflowNode", data: { label: "Fetch Website", type: "scraper", config: { scraperAction: "fetch-url", scraperInputField: "{business.website}" } }, position: { x: 100, y: 300 }, },
26
+ { id: "extract", type: "workflowNode", data: { label: "Extract Info", type: "scraper", config: { scraperAction: "extract-emails", scraperInputField: "{variables.scrapedData}" } }, position: { x: 100, y: 450 }, },
27
+ { id: "send-custom", type: "workflowNode", data: { label: "Send Personalized", type: "template", config: { templateId: "template-1" } }, position: { x: 100, y: 600 }, },
28
+ // No Path: Generic Send
29
+ { id: "send-generic", type: "workflowNode", data: { label: "Send Generic", type: "template", config: { templateId: "template-2" } }, position: { x: 500, y: 300 }, },
30
+ ],
31
+ edges: [
32
+ { id: "e1-cond", source: "1", target: "cond" },
33
+ // Yes branch
34
+ { id: "e-yes-1", source: "cond", target: "fetch", sourceHandle: "true", label: "Yes" },
35
+ { id: "e-yes-2", source: "fetch", target: "extract" },
36
+ { id: "e-yes-3", source: "extract", target: "send-custom" },
37
+ // No branch
38
+ { id: "e-no-1", source: "cond", target: "send-generic", sourceHandle: "false", label: "No" },
39
+ ],
40
+ },
41
  {
42
  id: "simple-follow-up",
43
  name: "Simple Follow-up",
components/notification-bell.tsx CHANGED
@@ -1,15 +1,22 @@
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 {
7
  Popover,
8
  PopoverContent,
9
  PopoverTrigger,
10
  } from "@/components/ui/popover";
 
 
 
 
 
 
 
11
  import { cn } from "@/lib/utils";
12
- import { useNotificationStore } from "@/store/notifications";
13
 
14
 
15
 
@@ -19,10 +26,12 @@ export function NotificationBell() {
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(() => {
@@ -39,94 +48,154 @@ export function NotificationBell() {
39
  }, [unreadCount]);
40
 
41
  return (
42
- <Popover>
43
- <PopoverTrigger asChild>
44
- <Button
45
- variant="ghost"
46
- size="icon"
47
- className="relative hover:scale-110"
48
- >
49
- <Bell className={cn(
50
- "h-5 w-5",
51
- animate && "animate-bounce"
52
- )} />
53
- {unreadCount > 0 && (
54
- <span className={cn(
55
- "absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs font-bold text-white bg-red-500 rounded-full",
56
- animate && "animate-ping"
57
- )}>
58
- {unreadCount > 9 ? "9+" : unreadCount}
59
- </span>
60
- )}
61
- </Button>
62
- </PopoverTrigger>
63
- <PopoverContent className="w-80 p-0" align="end">
64
- <div className="flex items-center justify-between p-4 border-b">
65
- <h3 className="font-semibold">Notifications</h3>
66
- {notifications.length > 0 && (
67
- <div className="flex gap-2">
68
- <Button
69
- variant="ghost"
70
- size="sm"
71
- onClick={markAllAsRead}
72
- className="text-xs"
73
- >
74
- Mark all read
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  </Button>
76
  <Button
77
- variant="ghost"
78
- size="sm"
79
- onClick={clearAll}
80
- className="text-xs text-destructive"
 
 
 
81
  >
82
- Clear all
83
  </Button>
84
  </div>
85
- )}
86
- </div>
87
- <div className="max-h-[400px] overflow-y-auto">
88
- {notifications.length === 0 ? (
89
- <div className="p-8 text-center text-muted-foreground">
90
- <Bell className="h-12 w-12 mx-auto mb-2 opacity-20" />
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(
109
- "h-2 w-2 rounded-full mt-2 shrink-0",
110
- notification.type === "success" && "bg-green-500",
111
- notification.type === "error" && "bg-red-500",
112
- notification.type === "warning" && "bg-yellow-500",
113
- notification.type === "info" && "bg-blue-500"
114
- )} />
115
- <div className="flex-1 space-y-1">
116
- <p className="text-sm font-medium">{notification.title}</p>
117
- <p className="text-xs text-muted-foreground">{notification.message}</p>
118
- <p className="text-xs text-muted-foreground">
119
- {formatTimestamp(notification.timestamp)}
120
- </p>
121
- </div>
122
- </div>
123
- </div>
124
- ))}
125
- </div>
126
- )}
127
- </div>
128
- </PopoverContent>
129
- </Popover>
130
  );
131
  }
132
 
 
1
  "use client";
2
 
3
  import { useState, useEffect, useRef } from "react";
4
+ import { Bell, X, AlertCircle, CheckCircle2, AlertTriangle, Info } from "lucide-react";
5
  import { Button } from "@/components/ui/button";
6
  import {
7
  Popover,
8
  PopoverContent,
9
  PopoverTrigger,
10
  } from "@/components/ui/popover";
11
+ import {
12
+ Dialog,
13
+ DialogContent,
14
+ DialogHeader,
15
+ DialogTitle,
16
+ DialogDescription,
17
+ } from "@/components/ui/dialog";
18
  import { cn } from "@/lib/utils";
19
+ import { useNotificationStore, type Notification } from "@/store/notifications";
20
 
21
 
22
 
 
26
  unreadCount,
27
  markAsRead,
28
  markAllAsRead,
29
+ clearAll,
30
+ removeNotification
31
  } = useNotificationStore();
32
 
33
  const [animate, setAnimate] = useState(false);
34
+ const [selectedNotification, setSelectedNotification] = useState<Notification | null>(null);
35
  const prevCount = useRef(unreadCount);
36
 
37
  useEffect(() => {
 
48
  }, [unreadCount]);
49
 
50
  return (
51
+ <>
52
+ <Popover>
53
+ <PopoverTrigger asChild>
54
+ <Button
55
+ variant="ghost"
56
+ size="icon"
57
+ className="relative hover:scale-110"
58
+ >
59
+ <Bell className={cn(
60
+ "h-5 w-5",
61
+ animate && "animate-bounce"
62
+ )} />
63
+ {unreadCount > 0 && (
64
+ <span className={cn(
65
+ "absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center text-xs font-bold text-white bg-red-500 rounded-full",
66
+ animate && "animate-ping"
67
+ )}>
68
+ {unreadCount > 9 ? "9+" : unreadCount}
69
+ </span>
70
+ )}
71
+ </Button>
72
+ </PopoverTrigger>
73
+ <PopoverContent className="w-80 p-0" align="end">
74
+ <div className="flex items-center justify-between p-4 border-b">
75
+ <h3 className="font-semibold">Notifications</h3>
76
+ {notifications.length > 0 && (
77
+ <div className="flex gap-2">
78
+ <Button
79
+ variant="ghost"
80
+ size="sm"
81
+ onClick={markAllAsRead}
82
+ className="text-xs"
83
+ >
84
+ Mark all read
85
+ </Button>
86
+ <Button
87
+ variant="ghost"
88
+ size="sm"
89
+ onClick={clearAll}
90
+ className="text-xs text-destructive"
91
+ >
92
+ Clear all
93
+ </Button>
94
+ </div>
95
+ )}
96
+ </div>
97
+ <div className="max-h-[400px] overflow-y-auto">
98
+ {notifications.length === 0 ? (
99
+ <div className="p-8 text-center text-muted-foreground">
100
+ <Bell className="h-12 w-12 mx-auto mb-2 opacity-20" />
101
+ <p className="text-sm">No notifications yet</p>
102
+ </div>
103
+ ) : (
104
+ <div className="divide-y relative">
105
+ {notifications.map((notification) => (
106
+ <div
107
+ key={notification.id}
108
+ className={cn(
109
+ "p-4 hover:bg-accent cursor-pointer transition-colors relative group",
110
+ !notification.read && "bg-blue-50 dark:bg-blue-950/20"
111
+ )}
112
+ onClick={() => {
113
+ markAsRead(notification.id);
114
+ setSelectedNotification(notification);
115
+ }}
116
+ >
117
+ <div className="flex items-start gap-3">
118
+ <div className={cn(
119
+ "h-2 w-2 rounded-full mt-2 shrink-0",
120
+ notification.type === "success" && "bg-green-500",
121
+ notification.type === "error" && "bg-red-500",
122
+ notification.type === "warning" && "bg-yellow-500",
123
+ notification.type === "info" && "bg-blue-500"
124
+ )} />
125
+ <div className="flex-1 space-y-1">
126
+ <p className="text-sm font-medium line-clamp-1">{notification.title}</p>
127
+ <p className="text-xs text-muted-foreground line-clamp-2">{notification.message}</p>
128
+ <p className="text-xs text-muted-foreground">
129
+ {formatTimestamp(notification.timestamp)}
130
+ </p>
131
+ </div>
132
+ <Button
133
+ variant="ghost"
134
+ size="icon"
135
+ className="h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity absolute top-2 right-2"
136
+ onClick={(e) => {
137
+ e.stopPropagation();
138
+ removeNotification(notification.id);
139
+ }}
140
+ >
141
+ <X className="h-3 w-3" />
142
+ </Button>
143
+ </div>
144
+ </div>
145
+ ))}
146
+ </div>
147
+ )}
148
+ </div>
149
+ </PopoverContent>
150
+ </Popover>
151
+
152
+ <Dialog open={!!selectedNotification} onOpenChange={(open) => !open && setSelectedNotification(null)}>
153
+ <DialogContent>
154
+ <DialogHeader>
155
+ <DialogTitle className="flex items-center gap-2">
156
+ {selectedNotification?.type === 'error' && <AlertCircle className="text-red-500 h-5 w-5" />}
157
+ {selectedNotification?.type === 'success' && <CheckCircle2 className="text-green-500 h-5 w-5" />}
158
+ {selectedNotification?.type === 'warning' && <AlertTriangle className="text-yellow-500 h-5 w-5" />}
159
+ {selectedNotification?.type === 'info' && <Info className="text-blue-500 h-5 w-5" />}
160
+ {selectedNotification?.title}
161
+ </DialogTitle>
162
+ <DialogDescription>
163
+ {formatTimestamp(selectedNotification?.timestamp || new Date())}
164
+ </DialogDescription>
165
+ </DialogHeader>
166
+ <div className="space-y-4">
167
+ <div className="text-sm text-foreground whitespace-pre-wrap">
168
+ {selectedNotification?.message}
169
+ </div>
170
+
171
+ {selectedNotification?.link && (
172
+ <Button asChild className="w-full">
173
+ <a href={selectedNotification.link}>
174
+ {selectedNotification.actionLabel || "View Details"}
175
+ </a>
176
+ </Button>
177
+ )}
178
+
179
+ <div className="flex justify-end gap-2">
180
+ <Button variant="outline" onClick={() => setSelectedNotification(null)}>
181
+ Close
182
  </Button>
183
  <Button
184
+ variant="destructive"
185
+ onClick={() => {
186
+ if (selectedNotification) {
187
+ removeNotification(selectedNotification.id);
188
+ setSelectedNotification(null);
189
+ }
190
+ }}
191
  >
192
+ Delete Notification
193
  </Button>
194
  </div>
195
+ </div>
196
+ </DialogContent>
197
+ </Dialog>
198
+ </>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  );
200
  }
201
 
components/ui/alert-dialog.tsx CHANGED
@@ -13,7 +13,7 @@ const AlertDialogTrigger = AlertDialogPrimitive.Trigger
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
@@ -28,7 +28,7 @@ const AlertDialogOverlay = React.forwardRef<
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>
 
13
  const AlertDialogPortal = AlertDialogPrimitive.Portal
14
 
15
  const AlertDialogOverlay = React.forwardRef<
16
+ React.ComponentRef<typeof AlertDialogPrimitive.Overlay>,
17
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
18
  >(({ className, ...props }, ref) => (
19
  <AlertDialogPrimitive.Overlay
 
28
  AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29
 
30
  const AlertDialogContent = React.forwardRef<
31
+ React.ComponentRef<typeof AlertDialogPrimitive.Content>,
32
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
33
  >(({ className, ...props }, ref) => (
34
  <AlertDialogPortal>
components/ui/checkbox.tsx ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client"
2
+
3
+ import * as React from "react"
4
+ import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
5
+ import { Check } from "lucide-react"
6
+
7
+ import { cn } from "@/lib/utils"
8
+
9
+ const Checkbox = React.forwardRef<
10
+ React.ComponentRef<typeof CheckboxPrimitive.Root>,
11
+ React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
12
+ >(({ className, ...props }, ref) => (
13
+ <CheckboxPrimitive.Root
14
+ ref={ref}
15
+ className={cn(
16
+ "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
17
+ className
18
+ )}
19
+ {...props}
20
+ >
21
+ <CheckboxPrimitive.Indicator
22
+ className={cn("flex items-center justify-center text-current")}
23
+ >
24
+ <Check className="h-4 w-4" />
25
+ </CheckboxPrimitive.Indicator>
26
+ </CheckboxPrimitive.Root>
27
+ ))
28
+ Checkbox.displayName = CheckboxPrimitive.Root.displayName
29
+
30
+ export { Checkbox }
components/ui/select.tsx CHANGED
@@ -73,7 +73,7 @@ const SelectContent = React.forwardRef<
73
  <SelectPrimitive.Content
74
  ref={ref}
75
  className={cn(
76
- "relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
77
  position === "popper" &&
78
  "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
79
  className
@@ -86,7 +86,7 @@ const SelectContent = React.forwardRef<
86
  className={cn(
87
  "p-1",
88
  position === "popper" &&
89
- "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
90
  )}
91
  >
92
  {children}
@@ -116,7 +116,7 @@ const SelectItem = React.forwardRef<
116
  <SelectPrimitive.Item
117
  ref={ref}
118
  className={cn(
119
- "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
120
  className
121
  )}
122
  {...props}
 
73
  <SelectPrimitive.Content
74
  ref={ref}
75
  className={cn(
76
+ "relative z-50 max-h-96 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md 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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
77
  position === "popper" &&
78
  "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
79
  className
 
86
  className={cn(
87
  "p-1",
88
  position === "popper" &&
89
+ "h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width)"
90
  )}
91
  >
92
  {children}
 
116
  <SelectPrimitive.Item
117
  ref={ref}
118
  className={cn(
119
+ "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
120
  className
121
  )}
122
  {...props}
hooks/use-api.ts CHANGED
@@ -1,8 +1,12 @@
1
  'use client'
2
  import { useState, useCallback } from "react";
 
3
 
4
  interface ApiOptions extends RequestInit {
5
  headers?: Record<string, string>;
 
 
 
6
  }
7
 
8
  interface ApiResponse<T> {
@@ -26,6 +30,7 @@ export function useApi<T = unknown>(): ApiResponse<T> {
26
  const [data, setData] = useState<T | null>(null);
27
  const [error, setError] = useState<string | null>(null);
28
  const [loading, setLoading] = useState<boolean>(false);
 
29
 
30
  const request = useCallback(
31
  async <R = T>(
@@ -75,11 +80,45 @@ export function useApi<T = unknown>(): ApiResponse<T> {
75
  // For now, let's keep setData best-effort if R extends T, usually users rely on return value for one-offs
76
  // safely ignore setData type mismatch or cast to any
77
  setData(result as unknown as T);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  return result as R;
79
  } catch {
80
  // If strictly typed as T, this might be an issue if T isn't void/null compliant
81
  // but for generic generic use usage, returning null on empty body is often handled
82
  setData(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  return null;
84
  }
85
 
@@ -89,12 +128,16 @@ export function useApi<T = unknown>(): ApiResponse<T> {
89
  message = err.message;
90
  }
91
  setError(message);
 
 
 
 
92
  return null;
93
  } finally {
94
  setLoading(false);
95
  }
96
  },
97
- []
98
  );
99
 
100
  const get = useCallback(<R = T>(url: string, options?: ApiOptions) => request<R>(url, "GET", undefined, options), [request]);
 
1
  'use client'
2
  import { useState, useCallback } from "react";
3
+ import { useNotification } from "@/hooks/use-notification";
4
 
5
  interface ApiOptions extends RequestInit {
6
  headers?: Record<string, string>;
7
+ throwOnError?: boolean;
8
+ skipNotification?: boolean;
9
+ successMessage?: string;
10
  }
11
 
12
  interface ApiResponse<T> {
 
30
  const [data, setData] = useState<T | null>(null);
31
  const [error, setError] = useState<string | null>(null);
32
  const [loading, setLoading] = useState<boolean>(false);
33
+ const { notify } = useNotification();
34
 
35
  const request = useCallback(
36
  async <R = T>(
 
80
  // For now, let's keep setData best-effort if R extends T, usually users rely on return value for one-offs
81
  // safely ignore setData type mismatch or cast to any
82
  setData(result as unknown as T);
83
+
84
+ // Success Notification for mutations
85
+ if (!options?.skipNotification && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
86
+ const title = "Success";
87
+ let message = "Operation completed successfully";
88
+
89
+ if (options?.successMessage) {
90
+ message = options.successMessage;
91
+ } else {
92
+ if (method === "POST") message = "Created successfully";
93
+ if (method === "PUT" || method === "PATCH") message = "Updated successfully";
94
+ if (method === "DELETE") message = "Deleted successfully";
95
+ }
96
+
97
+ notify(title, message, "success");
98
+ }
99
+
100
  return result as R;
101
  } catch {
102
  // If strictly typed as T, this might be an issue if T isn't void/null compliant
103
  // but for generic generic use usage, returning null on empty body is often handled
104
  setData(null);
105
+
106
+ // Success Notification for empty body mutations (like 204 No Content)
107
+ if (!options?.skipNotification && (method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
108
+ const title = "Success";
109
+ let message = "Operation completed successfully";
110
+
111
+ if (options?.successMessage) {
112
+ message = options.successMessage;
113
+ } else {
114
+ if (method === "POST") message = "Created successfully";
115
+ if (method === "PUT" || method === "PATCH") message = "Updated successfully";
116
+ if (method === "DELETE") message = "Deleted successfully";
117
+ }
118
+
119
+ notify(title, message, "success");
120
+ }
121
+
122
  return null;
123
  }
124
 
 
128
  message = err.message;
129
  }
130
  setError(message);
131
+ notify("Error", message, "error");
132
+ if (options?.throwOnError) {
133
+ throw err;
134
+ }
135
  return null;
136
  } finally {
137
  setLoading(false);
138
  }
139
  },
140
+ [notify]
141
  );
142
 
143
  const get = useCallback(<R = T>(url: string, options?: ApiOptions) => request<R>(url, "GET", undefined, options), [request]);
hooks/use-notification.ts ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { sendNotification } from "@/components/notification-bell";
5
+
6
+ export function useNotification() {
7
+ const notify = useCallback(async (
8
+ title: string,
9
+ message: string = "",
10
+ type: "info" | "success" | "warning" | "error" = "info",
11
+ options?: {
12
+ autoClose?: boolean;
13
+ duration?: number;
14
+ link?: string;
15
+ actionLabel?: string;
16
+ persist?: boolean; // If true (default), save to DB
17
+ }
18
+ ) => {
19
+ // 1. Show immediately in UI using the helper
20
+ sendNotification({
21
+ title,
22
+ message,
23
+ type,
24
+ ...options
25
+ });
26
+
27
+ // 2. Persist to DB (default to true unless explicitly false)
28
+ if (options?.persist !== false) {
29
+ try {
30
+ await fetch("/api/notifications", {
31
+ method: "POST",
32
+ headers: { "Content-Type": "application/json" },
33
+ body: JSON.stringify({
34
+ title,
35
+ message,
36
+ type,
37
+ }),
38
+ });
39
+ } catch (error) {
40
+ console.error("Failed to persist notification:", error);
41
+ }
42
+ }
43
+ }, []);
44
+
45
+ return { notify };
46
+ }
lib/auth.ts CHANGED
@@ -88,16 +88,22 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
88
  });
89
 
90
  if (existingUser) {
91
- // Update tokens
92
- await db
93
- .update(users)
94
- .set({
95
- accessToken: account?.access_token,
96
- refreshToken: account?.refresh_token,
97
  name: user.name,
98
  image: user.image,
99
  updatedAt: new Date(),
100
- })
 
 
 
 
 
 
 
 
 
 
101
  .where(eq(users.id, existingUser.id));
102
  } else {
103
  // Create new user
@@ -105,8 +111,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
105
  email: user.email,
106
  name: user.name,
107
  image: user.image,
108
- accessToken: account?.access_token,
109
- refreshToken: account?.refresh_token,
 
110
  });
111
  }
112
 
@@ -126,8 +133,6 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
126
  where: eq(users.email, session.user.email),
127
  });
128
 
129
- console.log("👤 DB User found:", dbUser ? dbUser.id : "null");
130
-
131
  if (dbUser) {
132
  // Use Database Truth (which is synced from Google on login)
133
  session.user.id = dbUser.id;
 
88
  });
89
 
90
  if (existingUser) {
91
+ // Update user details
92
+ const updateData: Record<string, unknown> = {
 
 
 
 
93
  name: user.name,
94
  image: user.image,
95
  updatedAt: new Date(),
96
+ };
97
+
98
+ // Only update tokens if logging in with Google (to preserve Gmail permissions)
99
+ if (account?.provider === "google") {
100
+ updateData.accessToken = account.access_token;
101
+ updateData.refreshToken = account.refresh_token;
102
+ }
103
+
104
+ await db
105
+ .update(users)
106
+ .set(updateData)
107
  .where(eq(users.id, existingUser.id));
108
  } else {
109
  // Create new user
 
111
  email: user.email,
112
  name: user.name,
113
  image: user.image,
114
+ // If first login is GitHub, these will be null/undefined, which is correct
115
+ accessToken: account?.provider === "google" ? account?.access_token : null,
116
+ refreshToken: account?.provider === "google" ? account?.refresh_token : null,
117
  });
118
  }
119
 
 
133
  where: eq(users.email, session.user.email),
134
  });
135
 
 
 
136
  if (dbUser) {
137
  // Use Database Truth (which is synced from Google on login)
138
  session.user.id = dbUser.id;
lib/email.ts CHANGED
@@ -6,13 +6,24 @@ interface SendEmailOptions {
6
  subject: string;
7
  body: string;
8
  accessToken: string;
 
9
  }
10
 
11
- export async function sendEmail(options: SendEmailOptions): Promise<boolean> {
12
- const { to, subject, body, accessToken } = options;
13
 
14
  try {
15
- const gmail = google.gmail({ version: "v1" });
 
 
 
 
 
 
 
 
 
 
16
 
17
  // Create email message
18
  const message = [
@@ -36,51 +47,87 @@ export async function sendEmail(options: SendEmailOptions): Promise<boolean> {
36
  requestBody: {
37
  raw: encodedMessage,
38
  },
39
- auth: new google.auth.OAuth2({
40
- credentials: {
41
- access_token: accessToken,
42
- },
43
- }),
44
  });
45
 
46
- return true;
47
  } catch (error) {
48
  console.error("Error sending email:", error);
49
- return false;
 
50
  }
51
  }
52
 
53
  export function interpolateTemplate(
54
  template: string,
55
- business: Business
 
 
 
 
 
 
 
 
 
56
  ): string {
57
- return template
58
  .replace(/\{brand_name\}/g, business.name)
59
  .replace(/\{email\}/g, business.email || "")
60
  .replace(/\{phone\}/g, business.phone || "")
61
  .replace(/\{website\}/g, business.website || "")
62
  .replace(/\{address\}/g, business.address || "")
63
  .replace(/\{category\}/g, business.category);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  }
65
 
66
  export async function sendColdEmail(
67
  business: Business,
68
  template: EmailTemplate,
69
- accessToken: string
70
- ): Promise<boolean> {
 
 
 
 
 
 
 
 
 
 
71
  if (!business.email) {
72
  console.log(`No email for business ${business.name}`);
73
- return false;
74
  }
75
 
76
- const subject = interpolateTemplate(template.subject, business);
77
- const body = interpolateTemplate(template.body, business);
78
 
79
  return await sendEmail({
80
  to: business.email,
81
  subject,
82
  body,
83
  accessToken,
 
84
  });
85
  }
86
 
@@ -132,7 +179,6 @@ export class EmailService {
132
  });
133
 
134
  if (pendingBusinesses.length === 0) {
135
- console.log("No pending emails");
136
  return;
137
  }
138
 
@@ -151,7 +197,7 @@ export class EmailService {
151
 
152
  // Send emails
153
  for (const business of pendingBusinesses) {
154
- const success = await sendColdEmail(
155
  business,
156
  defaultTemplate,
157
  accessToken
@@ -177,6 +223,7 @@ export class EmailService {
177
  subject: interpolateTemplate(defaultTemplate.subject, business),
178
  body: interpolateTemplate(defaultTemplate.body, business),
179
  status: success ? "sent" : "failed",
 
180
  sentAt: success ? new Date() : null,
181
  });
182
 
 
6
  subject: string;
7
  body: string;
8
  accessToken: string;
9
+ refreshToken?: string;
10
  }
11
 
12
+ export async function sendEmail(options: SendEmailOptions): Promise<{ success: boolean; error?: string }> {
13
+ const { to, subject, body, accessToken, refreshToken } = options;
14
 
15
  try {
16
+ const oauth2Client = new google.auth.OAuth2(
17
+ process.env.GOOGLE_CLIENT_ID,
18
+ process.env.GOOGLE_CLIENT_SECRET
19
+ );
20
+
21
+ oauth2Client.setCredentials({
22
+ access_token: accessToken,
23
+ refresh_token: refreshToken,
24
+ });
25
+
26
+ const gmail = google.gmail({ version: "v1", auth: oauth2Client });
27
 
28
  // Create email message
29
  const message = [
 
47
  requestBody: {
48
  raw: encodedMessage,
49
  },
 
 
 
 
 
50
  });
51
 
52
+ return { success: true };
53
  } catch (error) {
54
  console.error("Error sending email:", error);
55
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
56
+ return { success: false, error: errorMessage };
57
  }
58
  }
59
 
60
  export function interpolateTemplate(
61
  template: string,
62
+ business: Business,
63
+ sender?: {
64
+ name?: string | null;
65
+ email?: string | null;
66
+ phone?: string | null;
67
+ company?: string | null;
68
+ website?: string | null;
69
+ jobTitle?: string | null;
70
+ customVariables?: Record<string, string | number | boolean | null> | null;
71
+ }
72
  ): string {
73
+ let result = template
74
  .replace(/\{brand_name\}/g, business.name)
75
  .replace(/\{email\}/g, business.email || "")
76
  .replace(/\{phone\}/g, business.phone || "")
77
  .replace(/\{website\}/g, business.website || "")
78
  .replace(/\{address\}/g, business.address || "")
79
  .replace(/\{category\}/g, business.category);
80
+
81
+ if (sender) {
82
+ result = result
83
+ .replace(/\{sender_name\}/g, sender.name || "")
84
+ .replace(/\{sender_email\}/g, sender.email || "")
85
+ .replace(/\{sender_phone\}/g, sender.phone || "")
86
+ .replace(/\{sender_company\}/g, sender.company || "")
87
+ .replace(/\{sender_website\}/g, sender.website || "")
88
+ .replace(/\{sender_job_title\}/g, sender.jobTitle || "");
89
+
90
+ // Custom Sender Variables
91
+ if (sender.customVariables) {
92
+ Object.entries(sender.customVariables).forEach(([key, value]) => {
93
+ const regex = new RegExp(`\\{${key}\\}`, "g");
94
+ result = result.replace(regex, typeof value === 'string' ? value : String(value));
95
+ });
96
+ }
97
+ }
98
+
99
+ return result;
100
  }
101
 
102
  export async function sendColdEmail(
103
  business: Business,
104
  template: EmailTemplate,
105
+ accessToken: string,
106
+ sender?: {
107
+ name?: string | null;
108
+ email?: string | null;
109
+ phone?: string | null;
110
+ company?: string | null;
111
+ website?: string | null;
112
+ jobTitle?: string | null;
113
+ customVariables?: Record<string, string | number | boolean | null> | null;
114
+ refreshToken?: string | null;
115
+ }
116
+ ): Promise<{ success: boolean; error?: string }> {
117
  if (!business.email) {
118
  console.log(`No email for business ${business.name}`);
119
+ return { success: false, error: "No email address" };
120
  }
121
 
122
+ const subject = interpolateTemplate(template.subject, business, sender);
123
+ const body = interpolateTemplate(template.body, business, sender);
124
 
125
  return await sendEmail({
126
  to: business.email,
127
  subject,
128
  body,
129
  accessToken,
130
+ refreshToken: sender?.refreshToken || undefined,
131
  });
132
  }
133
 
 
179
  });
180
 
181
  if (pendingBusinesses.length === 0) {
 
182
  return;
183
  }
184
 
 
197
 
198
  // Send emails
199
  for (const business of pendingBusinesses) {
200
+ const { success, error } = await sendColdEmail(
201
  business,
202
  defaultTemplate,
203
  accessToken
 
223
  subject: interpolateTemplate(defaultTemplate.subject, business),
224
  body: interpolateTemplate(defaultTemplate.body, business),
225
  status: success ? "sent" : "failed",
226
+ errorMessage: error,
227
  sentAt: success ? new Date() : null,
228
  });
229
 
lib/queue.ts CHANGED
@@ -3,9 +3,9 @@
3
  import { Queue, Worker, Job } from "bullmq";
4
  import Redis from "ioredis";
5
  import { db } from "@/db";
6
- import { emailLogs, businesses, emailTemplates } from "@/db/schema";
7
- import { eq } from "drizzle-orm";
8
- import { sendColdEmail } from "./email";
9
  import type { ScraperSourceName } from "./scrapers/types";
10
 
11
  // Redis connection
@@ -94,25 +94,81 @@ export const emailWorker = new Worker(
94
  if (!template) {
95
  throw new Error("Template not found");
96
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
 
 
 
 
 
 
 
98
  // Send email
99
- await sendColdEmail(
100
  business,
101
  template,
102
- accessToken
 
103
  );
104
 
105
- // Log success
 
 
 
 
 
 
 
 
 
 
 
106
  await db.insert(emailLogs).values({
107
  userId,
108
- businessId,
109
- templateId,
110
- subject: "Cold Outreach",
111
- body: "Email sent via workflow",
112
- status: "sent",
113
- sentAt: new Date(),
 
114
  });
115
 
 
 
 
 
116
  return { success: true, businessId };
117
  } catch (error: any) {
118
  // Log failure
 
3
  import { Queue, Worker, Job } from "bullmq";
4
  import Redis from "ioredis";
5
  import { db } from "@/db";
6
+ import { emailLogs, businesses, emailTemplates, users } from "@/db/schema";
7
+ import { eq, sql, and, gte, lt } from "drizzle-orm";
8
+ import { interpolateTemplate, sendColdEmail } from "./email";
9
  import type { ScraperSourceName } from "./scrapers/types";
10
 
11
  // Redis connection
 
94
  if (!template) {
95
  throw new Error("Template not found");
96
  }
97
+ // Check daily limit (50 emails/day)
98
+ const startOfDay = new Date();
99
+ startOfDay.setHours(0, 0, 0, 0);
100
+
101
+ const endOfDay = new Date();
102
+ endOfDay.setHours(23, 59, 59, 999);
103
+
104
+ const [usage] = await db
105
+ .select({ count: sql<number>`count(*)` })
106
+ .from(emailLogs)
107
+ .where(
108
+ and(
109
+ eq(emailLogs.userId, userId),
110
+ eq(emailLogs.status, "sent"),
111
+ gte(emailLogs.sentAt, startOfDay),
112
+ lt(emailLogs.sentAt, endOfDay)
113
+ )
114
+ );
115
+
116
+ if (usage && usage.count >= 50) {
117
+ // Calculate time until next day
118
+ const now = new Date();
119
+ const tomorrow = new Date(now);
120
+ tomorrow.setDate(tomorrow.getDate() + 1);
121
+ tomorrow.setHours(0, 0, 0, 0);
122
+ const delay = tomorrow.getTime() - now.getTime() + 60000; // 1 min buffer
123
+
124
+ console.log(`⚠️ Daily email limit reached (${usage.count}/50). Delaying job ${job.id} by ${Math.round(delay / 1000 / 60)} minutes.`);
125
+
126
+ await job.moveToDelayed(Date.now() + delay, job.token);
127
+ return { delayed: true, reason: "Daily limit reached" };
128
+ }
129
 
130
+ const sender = await db.query.users.findFirst({
131
+ where: eq(users.id, userId),
132
+ });
133
+
134
+ if (!sender) throw new Error("Sender not found");
135
+
136
+ // Send emails
137
  // Send email
138
+ const { success, error } = await sendColdEmail(
139
  business,
140
  template,
141
+ accessToken,
142
+ sender
143
  );
144
 
145
+ // Update business
146
+ await db
147
+ .update(businesses)
148
+ .set({
149
+ emailSent: true,
150
+ emailSentAt: new Date(),
151
+ emailStatus: success ? "sent" : "failed",
152
+ updatedAt: new Date(),
153
+ })
154
+ .where(eq(businesses.id, business.id));
155
+
156
+ // Log email
157
  await db.insert(emailLogs).values({
158
  userId,
159
+ businessId: business.id,
160
+ templateId: template.id,
161
+ subject: interpolateTemplate(template.subject, business, sender),
162
+ body: interpolateTemplate(template.body, business, sender),
163
+ status: success ? "sent" : "failed",
164
+ errorMessage: error,
165
+ sentAt: success ? new Date() : null,
166
  });
167
 
168
+ if (!success) {
169
+ throw new Error(error || "Failed to send email");
170
+ }
171
+
172
  return { success: true, businessId };
173
  } catch (error: any) {
174
  // Log failure
lib/workflow-executor.ts CHANGED
@@ -55,11 +55,22 @@ export class WorkflowExecutor {
55
  case "condition":
56
  const conditionResult = this.evaluateCondition(node.data.config?.condition || "");
57
  logs.push(`Condition "${node.data.config?.condition}" evaluated to: ${conditionResult}`);
58
- if (!conditionResult) {
59
- logs.push("Condition failed, stopping branch");
 
 
 
 
60
  return;
61
  }
62
- break;
 
 
 
 
 
 
 
63
 
64
  case "template":
65
  const templateId = node.data.config?.templateId;
@@ -101,6 +112,10 @@ export class WorkflowExecutor {
101
  case "apiRequest":
102
  await this.executeApiRequest(node.data.config, logs);
103
  break;
 
 
 
 
104
  }
105
 
106
  // Find and execute next nodes
@@ -110,19 +125,72 @@ export class WorkflowExecutor {
110
  }
111
  }
112
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
  private evaluateCondition(condition: string): boolean {
 
 
114
  try {
 
115
  if (condition.startsWith("!")) {
116
- const field = condition.slice(1);
117
- const value = this.context.businessData[field];
118
  return !value || value === "";
119
  }
 
 
120
  if (condition.includes("==")) {
121
- const [field, expectedValue] = condition.split("==").map(s => s.trim().replace(/"/g, ""));
122
- return String(this.context.businessData[field]) === expectedValue;
 
 
 
 
 
 
 
 
 
 
 
123
  }
124
- const value = this.context.businessData[condition];
 
 
 
125
  return !!value && value !== "";
 
126
  } catch (error) {
127
  console.error("Condition evaluation error:", error);
128
  return false;
@@ -207,13 +275,12 @@ export class WorkflowExecutor {
207
  }
208
 
209
  private async executeAITask(prompt: string, contextData: string | undefined, logs: string[]): Promise<void> {
210
- let processedPrompt = prompt;
211
- for (const [key, value] of Object.entries(this.context.businessData)) {
212
- processedPrompt = processedPrompt.replace(`{${key}}`, String(value || ""));
213
- }
214
 
215
  if (contextData) {
216
- processedPrompt += `\n\nContext Data:\n${contextData}`;
 
217
  }
218
 
219
  // Try to get API key from user in DB (assuming stored in env or user record)
@@ -241,18 +308,42 @@ export class WorkflowExecutor {
241
  private async executeApiRequest(config: NodeData["config"], logs: string[]): Promise<void> {
242
  if (!config?.url) return;
243
 
244
- logs.push(`Making ${config.method} request to ${config.url}`);
 
 
 
 
 
245
  try {
246
- const headers = config.headers ? JSON.parse(config.headers) : {};
247
- const body = config.body ? config.body : undefined;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
 
249
- const response = await fetch(config.url, {
250
- method: config.method || "GET",
251
  headers: {
252
  ...headers,
253
  "Content-Type": "application/json"
254
  },
255
- body: config.method !== "GET" ? body : undefined
256
  });
257
 
258
  const text = await response.text();
@@ -263,6 +354,61 @@ export class WorkflowExecutor {
263
  }
264
  }
265
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
266
  private getNextNodes(nodeId: string): Node<NodeData>[] {
267
  const outgoingEdges = this.edges.filter(e => e.source === nodeId);
268
  return outgoingEdges
 
55
  case "condition":
56
  const conditionResult = this.evaluateCondition(node.data.config?.condition || "");
57
  logs.push(`Condition "${node.data.config?.condition}" evaluated to: ${conditionResult}`);
58
+
59
+ const handleToFollow = conditionResult ? "true" : "false";
60
+ const relevantEdges = this.edges.filter(e => e.source === node.id && e.sourceHandle === handleToFollow);
61
+
62
+ if (relevantEdges.length === 0) {
63
+ logs.push(`No path found for result ${conditionResult} (handle: ${handleToFollow})`);
64
  return;
65
  }
66
+
67
+ for (const edge of relevantEdges) {
68
+ const nextNode = this.nodes.find(n => n.id === edge.target);
69
+ if (nextNode) {
70
+ await this.executeNode(nextNode, logs);
71
+ }
72
+ }
73
+ return; // Stop default flow, we handled it explicitly
74
 
75
  case "template":
76
  const templateId = node.data.config?.templateId;
 
112
  case "apiRequest":
113
  await this.executeApiRequest(node.data.config, logs);
114
  break;
115
+
116
+ case "scraper":
117
+ await this.executeScraperTask(node.data.config, logs);
118
+ break;
119
  }
120
 
121
  // Find and execute next nodes
 
125
  }
126
  }
127
 
128
+ private resolveValue(path: string): unknown {
129
+ // Strip curly braces if present
130
+ const cleanPath = path.replace(/^\{|\}$/g, "");
131
+
132
+ // Check for explicit "business." prefix
133
+ if (cleanPath.startsWith("business.")) {
134
+ const field = cleanPath.split(".")[1];
135
+ return this.context.businessData[field];
136
+ }
137
+
138
+ // Check for "variables." prefix (custom workflow variables)
139
+ if (cleanPath.startsWith("variables.") || cleanPath.startsWith("variable.")) {
140
+ const field = cleanPath.split(".")[1];
141
+ return this.context.variables[field];
142
+ }
143
+
144
+ // Fallback: Check business data first, then variables
145
+ if (cleanPath in this.context.businessData) {
146
+ return this.context.businessData[cleanPath];
147
+ }
148
+
149
+ return this.context.variables[cleanPath];
150
+ }
151
+
152
+ // Helper to replace all {variables} in a string
153
+ private interpolateString(text: string): string {
154
+ if (!text) return "";
155
+ return text.replace(/\{([^}]+)\}/g, (match, path) => {
156
+ const value = this.resolveValue(path);
157
+ return value !== undefined && value !== null ? String(value) : "";
158
+ });
159
+ }
160
+
161
  private evaluateCondition(condition: string): boolean {
162
+ if (!condition) return false;
163
+
164
  try {
165
+ // 1. Handle Negation "!"
166
  if (condition.startsWith("!")) {
167
+ const path = condition.slice(1).trim();
168
+ const value = this.resolveValue(path);
169
  return !value || value === "";
170
  }
171
+
172
+ // 2. Handle Equality "=="
173
  if (condition.includes("==")) {
174
+ const [left, right] = condition.split("==").map(s => s.trim());
175
+ const leftValue = String(this.resolveValue(left));
176
+ // Handle quoted string on the right side
177
+ const rightValue = right.replace(/^["']|["']$/g, "");
178
+ return leftValue === rightValue;
179
+ }
180
+
181
+ // 3. Handle Inequality "!="
182
+ if (condition.includes("!=")) {
183
+ const [left, right] = condition.split("!=").map(s => s.trim());
184
+ const leftValue = String(this.resolveValue(left));
185
+ const rightValue = right.replace(/^["']|["']$/g, "");
186
+ return leftValue !== rightValue;
187
  }
188
+
189
+ // 4. Handle truthiness (just the variable name)
190
+ const value = this.resolveValue(condition);
191
+ console.log(`[Condition Debug] checking truthiness of "${condition}", resolved value:`, value);
192
  return !!value && value !== "";
193
+
194
  } catch (error) {
195
  console.error("Condition evaluation error:", error);
196
  return false;
 
275
  }
276
 
277
  private async executeAITask(prompt: string, contextData: string | undefined, logs: string[]): Promise<void> {
278
+ // Interpolate the prompt with all available variables
279
+ let processedPrompt = this.interpolateString(prompt);
 
 
280
 
281
  if (contextData) {
282
+ // Also interpolate context data if needed, or just append
283
+ processedPrompt += `\n\nContext Data:\n${this.interpolateString(contextData)}`;
284
  }
285
 
286
  // Try to get API key from user in DB (assuming stored in env or user record)
 
308
  private async executeApiRequest(config: NodeData["config"], logs: string[]): Promise<void> {
309
  if (!config?.url) return;
310
 
311
+ // Interpolate URL, Headers, and Body
312
+ const url = this.interpolateString(config.url);
313
+ const method = config.method || "GET";
314
+
315
+ logs.push(`Making ${method} request to ${url}`);
316
+
317
  try {
318
+ let headers = {};
319
+ if (config.headers) {
320
+ const interpolatedHeaders = this.interpolateString(config.headers);
321
+ try {
322
+ headers = JSON.parse(interpolatedHeaders);
323
+ } catch {
324
+ logs.push("Warning: Failed to parse headers JSON");
325
+ }
326
+ }
327
+
328
+ let body = undefined;
329
+ if (config.body && method !== "GET") {
330
+ const interpolatedBody = this.interpolateString(config.body);
331
+ // Try to parse if proper JSON, otherwise send as string possibly?
332
+ // Usually API expects JSON object for body if content-type is json
333
+ try {
334
+ body = JSON.parse(interpolatedBody);
335
+ } catch {
336
+ body = interpolatedBody;
337
+ }
338
+ }
339
 
340
+ const response = await fetch(url, {
341
+ method,
342
  headers: {
343
  ...headers,
344
  "Content-Type": "application/json"
345
  },
346
+ body: body ? JSON.stringify(body) : undefined
347
  });
348
 
349
  const text = await response.text();
 
354
  }
355
  }
356
 
357
+ private async executeScraperTask(config: NodeData["config"], logs: string[]): Promise<void> {
358
+ const action = config?.scraperAction || "extract-emails";
359
+ const inputVar = config?.scraperInputField || "";
360
+
361
+ // Resolve input content
362
+ const content = this.resolveValue(inputVar); // Can be a string or object
363
+ const textContent = content === undefined || content === null ? "" : (typeof content === "string" ? content : JSON.stringify(content));
364
+
365
+ logs.push(`Running Scraper Action: ${action}`);
366
+ logs.push(`> Input Content Length: ${textContent.length} chars (Source: ${inputVar || "Direct Input"})`);
367
+
368
+ if (!textContent) {
369
+ logs.push("> Warning: Input content is empty. Skipping extraction.");
370
+ this.context.variables.scrapedData = null;
371
+ return;
372
+ }
373
+
374
+ let result: unknown = null;
375
+
376
+ if (action === "fetch-url") {
377
+ let url = textContent.trim();
378
+ if (!url.startsWith("http")) url = "https://" + url;
379
+
380
+ try {
381
+ logs.push(`> Fetching URL: ${url}`);
382
+ const response = await fetch(url);
383
+ if (!response.ok) throw new Error(`Status ${response.status}`);
384
+ const html = await response.text();
385
+ result = html;
386
+ logs.push(`> Success: Fetched ${html.length} chars from URL`);
387
+ } catch (e) {
388
+ logs.push(`> Error: Failed to fetch URL: ${e}`);
389
+ result = null;
390
+ }
391
+ } else if (action === "extract-emails") {
392
+ const emailRegex = /([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)/gi;
393
+ const matches = textContent.match(emailRegex);
394
+ const emails = matches ? [...new Set(matches)] : [];
395
+ result = emails;
396
+ logs.push(`> Success: Extracted ${emails.length} unique emails: ${emails.slice(0, 3).join(", ")}${emails.length > 3 ? "..." : ""}`);
397
+ } else if (action === "clean-html") {
398
+ result = textContent.replace(/<[^>]*>/g, "");
399
+ logs.push(`> Success: Cleaned HTML tags. New length: ${String(result).length}`);
400
+ } else if (action === "markdown") {
401
+ // Simple mock markdown conversion
402
+ result = textContent.replace(/<[^>]*>/g, "").replace(/\n\s*\n/g, "\n\n");
403
+ logs.push(`> Success: Converted to Markdown. New length: ${String(result).length}`);
404
+ } else if (action === "summarize") {
405
+ result = textContent.substring(0, 200) + "...";
406
+ logs.push(`> Success: Summarized content (Truncated to 200 chars)`);
407
+ }
408
+
409
+ this.context.variables.scrapedData = result;
410
+ }
411
+
412
  private getNextNodes(nodeId: string): Node<NodeData>[] {
413
  const outgoingEdges = this.edges.filter(e => e.source === nodeId);
414
  return outgoingEdges
package.json CHANGED
@@ -29,6 +29,7 @@
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",
 
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-checkbox": "^1.3.3",
33
  "@radix-ui/react-dialog": "^1.1.15",
34
  "@radix-ui/react-dropdown-menu": "^2.1.16",
35
  "@radix-ui/react-label": "^2.1.8",
pnpm-lock.yaml CHANGED
@@ -35,6 +35,9 @@ importers:
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)
 
 
 
38
  '@radix-ui/react-dialog':
39
  specifier: ^1.1.15
40
  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)
@@ -1168,6 +1171,19 @@ packages:
1168
  '@types/react-dom':
1169
  optional: true
1170
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1171
  '@radix-ui/react-collection@1.1.7':
1172
  resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
1173
  peerDependencies:
@@ -5118,6 +5134,22 @@ snapshots:
5118
  '@types/react': 19.2.8
5119
  '@types/react-dom': 19.2.3(@types/react@19.2.8)
5120
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5121
  '@radix-ui/react-collection@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)':
5122
  dependencies:
5123
  '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(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)
38
+ '@radix-ui/react-checkbox':
39
+ specifier: ^1.3.3
40
+ version: 1.3.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)
41
  '@radix-ui/react-dialog':
42
  specifier: ^1.1.15
43
  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)
 
1171
  '@types/react-dom':
1172
  optional: true
1173
 
1174
+ '@radix-ui/react-checkbox@1.3.3':
1175
+ resolution: {integrity: sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==}
1176
+ peerDependencies:
1177
+ '@types/react': '*'
1178
+ '@types/react-dom': '*'
1179
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
1180
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
1181
+ peerDependenciesMeta:
1182
+ '@types/react':
1183
+ optional: true
1184
+ '@types/react-dom':
1185
+ optional: true
1186
+
1187
  '@radix-ui/react-collection@1.1.7':
1188
  resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==}
1189
  peerDependencies:
 
5134
  '@types/react': 19.2.8
5135
  '@types/react-dom': 19.2.3(@types/react@19.2.8)
5136
 
5137
+ '@radix-ui/react-checkbox@1.3.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)':
5138
+ dependencies:
5139
+ '@radix-ui/primitive': 1.1.3
5140
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
5141
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.8)(react@19.2.3)
5142
+ '@radix-ui/react-presence': 1.1.5(@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)
5143
+ '@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)
5144
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.8)(react@19.2.3)
5145
+ '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.8)(react@19.2.3)
5146
+ '@radix-ui/react-use-size': 1.1.1(@types/react@19.2.8)(react@19.2.3)
5147
+ react: 19.2.3
5148
+ react-dom: 19.2.3(react@19.2.3)
5149
+ optionalDependencies:
5150
+ '@types/react': 19.2.8
5151
+ '@types/react-dom': 19.2.3(@types/react@19.2.8)
5152
+
5153
  '@radix-ui/react-collection@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)':
5154
  dependencies:
5155
  '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.8)(react@19.2.3)
store/notifications.ts CHANGED
@@ -66,9 +66,15 @@ export const useNotificationStore = create<NotificationStore>((set) => ({
66
  },
67
 
68
  removeNotification: (id) =>
69
- set((state) => ({
70
- notifications: state.notifications.filter((n) => n.id !== id),
71
- })),
 
 
 
 
 
 
72
 
73
  markAsRead: (id) =>
74
  set((state) => ({
 
66
  },
67
 
68
  removeNotification: (id) =>
69
+ set((state) => {
70
+ const notification = state.notifications.find((n) => n.id === id);
71
+ if (!notification) return state; // No change if not found
72
+
73
+ return {
74
+ notifications: state.notifications.filter((n) => n.id !== id),
75
+ unreadCount: notification.read ? state.unreadCount : Math.max(0, state.unreadCount - 1),
76
+ };
77
+ }),
78
 
79
  markAsRead: (id) =>
80
  set((state) => ({