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 +37 -0
- app/api/businesses/route.ts +6 -7
- app/api/email/send/route.ts +12 -5
- app/api/notifications/route.ts +77 -0
- app/api/settings/delete-data/route.ts +36 -0
- app/api/settings/route.ts +2 -0
- app/api/workflows/test-node/route.ts +58 -7
- app/dashboard/businesses/page.tsx +79 -18
- app/dashboard/page.tsx +16 -25
- app/dashboard/settings/page.tsx +83 -14
- app/dashboard/templates/page.tsx +77 -35
- components/dashboard/business-table.tsx +10 -9
- components/email/email-editor.tsx +14 -0
- components/mail/mail-settings.tsx +80 -0
- components/node-editor/node-config-dialog.tsx +238 -49
- components/node-editor/node-editor.tsx +143 -19
- components/node-editor/workflow-node.tsx +25 -2
- components/node-editor/workflow-templates-dialog.tsx +24 -0
- components/notification-bell.tsx +155 -86
- components/ui/alert-dialog.tsx +2 -2
- components/ui/checkbox.tsx +30 -0
- components/ui/select.tsx +3 -3
- hooks/use-api.ts +44 -1
- hooks/use-notification.ts +46 -0
- lib/auth.ts +16 -11
- lib/email.ts +66 -19
- lib/queue.ts +68 -12
- lib/workflow-executor.ts +165 -19
- package.json +1 -0
- pnpm-lock.yaml +32 -0
- store/notifications.ts +9 -3
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 38 |
-
|
|
|
|
| 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
|
| 61 |
-
|
|
|
|
| 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:
|
|
|
|
| 110 |
status = "error";
|
| 111 |
-
logs.push(`Runtime Error: ${
|
| 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 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 90 |
-
|
| 91 |
}
|
| 92 |
};
|
| 93 |
|
| 94 |
return (
|
| 95 |
<div className="space-y-6 pt-6">
|
| 96 |
-
<div className="flex justify-
|
| 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 |
-
|
| 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 |
-
<
|
| 317 |
-
type="checkbox"
|
| 318 |
checked={scrapingSources.includes("google-maps")}
|
| 319 |
-
|
| 320 |
-
if (
|
| 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 |
-
<
|
| 329 |
-
type="checkbox"
|
| 330 |
checked={scrapingSources.includes("google-search")}
|
| 331 |
-
|
| 332 |
-
if (
|
| 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 |
-
<
|
| 341 |
-
type="checkbox"
|
| 342 |
checked={scrapingSources.includes("linkedin")}
|
| 343 |
-
|
| 344 |
-
if (
|
| 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 |
-
<
|
| 353 |
-
type="checkbox"
|
| 354 |
checked={scrapingSources.includes("facebook")}
|
| 355 |
-
|
| 356 |
-
if (
|
| 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 |
-
<
|
| 365 |
-
type="checkbox"
|
| 366 |
checked={scrapingSources.includes("instagram")}
|
| 367 |
-
|
| 368 |
-
if (
|
| 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
|
| 473 |
</CardDescription>
|
| 474 |
</CardHeader>
|
| 475 |
<CardContent className="space-y-6">
|
| 476 |
-
{/*
|
| 477 |
<div className="space-y-2">
|
| 478 |
-
<
|
| 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 |
-
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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",
|
| 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
|
| 153 |
<CardHeader>
|
| 154 |
<div className="flex items-start justify-between">
|
| 155 |
-
<
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
<
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
</div>
|
| 163 |
</CardHeader>
|
| 164 |
<CardContent>
|
| 165 |
<div
|
| 166 |
-
className="text-sm text-muted-foreground line-clamp-3 mb-
|
| 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 |
-
<
|
| 70 |
-
type="checkbox"
|
| 71 |
-
className="w-4 h-4 rounded border-gray-300"
|
| 72 |
checked={businesses.length > 0 && selectedIds.length === businesses.length}
|
| 73 |
-
|
|
|
|
| 74 |
/>
|
| 75 |
</TableHead>
|
| 76 |
)}
|
|
@@ -88,12 +89,12 @@ export function BusinessTable({
|
|
| 88 |
<TableRow key={business.id}>
|
| 89 |
{onSelectionChange && (
|
| 90 |
<TableCell>
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
className="w-4 h-4 rounded border-gray-300"
|
| 94 |
checked={selectedIds.includes(business.id)}
|
| 95 |
-
|
| 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 "Invalid Credentials" 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 == "restaurant"</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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
<Textarea
|
| 151 |
id="customCode"
|
| 152 |
placeholder="// JavaScript code here 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 'Done' 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
<Textarea
|
| 477 |
className="font-mono text-xs"
|
| 478 |
-
rows={
|
| 479 |
-
placeholder='{ "email": "test@example.com", "
|
| 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 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 == "restaurant"</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 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: "
|
| 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
|
| 326 |
if (!contextMenu) return;
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 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
|
| 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={
|
| 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
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 43 |
-
<
|
| 44 |
-
<
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
<
|
| 65 |
-
<
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
<
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
</Button>
|
| 76 |
<Button
|
| 77 |
-
variant="
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
| 81 |
>
|
| 82 |
-
|
| 83 |
</Button>
|
| 84 |
</div>
|
| 85 |
-
|
| 86 |
-
</
|
| 87 |
-
|
| 88 |
-
|
| 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.
|
| 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.
|
| 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-
|
| 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-
|
| 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-
|
| 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
|
| 92 |
-
|
| 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 |
-
|
| 109 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 50 |
}
|
| 51 |
}
|
| 52 |
|
| 53 |
export function interpolateTemplate(
|
| 54 |
template: string,
|
| 55 |
-
business: Business
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
): string {
|
| 57 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
await db.insert(emailLogs).values({
|
| 107 |
userId,
|
| 108 |
-
businessId,
|
| 109 |
-
templateId,
|
| 110 |
-
subject:
|
| 111 |
-
body:
|
| 112 |
-
status: "sent",
|
| 113 |
-
|
|
|
|
| 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 |
-
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
return;
|
| 61 |
}
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 117 |
-
const value = this.
|
| 118 |
return !value || value === "";
|
| 119 |
}
|
|
|
|
|
|
|
| 120 |
if (condition.includes("==")) {
|
| 121 |
-
const [
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
}
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 211 |
-
|
| 212 |
-
processedPrompt = processedPrompt.replace(`{${key}}`, String(value || ""));
|
| 213 |
-
}
|
| 214 |
|
| 215 |
if (contextData) {
|
| 216 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
try {
|
| 246 |
-
|
| 247 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
-
const response = await fetch(
|
| 250 |
-
method
|
| 251 |
headers: {
|
| 252 |
...headers,
|
| 253 |
"Content-Type": "application/json"
|
| 254 |
},
|
| 255 |
-
body:
|
| 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 |
-
|
| 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) => ({
|