Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions app/(dashboard)/campaigns/[id]/flow/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { AutomationFlowEditor } from "@/components/flow-builder/flow-builder";
import { getCurrentWorkspaceId } from "@/lib/auth";
import { prisma } from "@/lib/db/client";
import { parseFlowEdges, parseFlowNodes } from "@/lib/flow/engine";

type FlowPageProps = { params: Promise<{ id: string }> };

export default async function CampaignFlowPage({ params }: FlowPageProps) {
const { id } = await params;

const workspaceId = await getCurrentWorkspaceId();
if (!workspaceId) {
redirect("/login");
}

const automation = await prisma.automation.findFirst({
where: { id, workspaceId },
select: { id: true, name: true, nodes: true, edges: true },
});

if (!automation) {
notFound();
}

const nodes = parseFlowNodes(automation.nodes);
const edges = parseFlowEdges(automation.edges);

return (
<div className="space-y-4">
<div className="space-y-1">
<Link
href={`/campaigns/${automation.id}`}
className="text-sm text-muted hover:text-foreground"
>
&larr; {automation.name}
</Link>
<h1 className="text-lg font-semibold">Flow</h1>
<p className="text-sm text-muted">
{nodes.length === 0
? "This campaign runs on its keyword and DM settings. Build a flow here to take over from them."
: "This flow replaces the campaign's keyword and DM settings. Save an empty canvas to go back to them."}
</p>
</div>

<AutomationFlowEditor
automationId={automation.id}
initialNodes={nodes}
initialEdges={edges}
/>
</div>
);
}
57 changes: 57 additions & 0 deletions app/(dashboard)/contacts/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/db/client";
import { ensureWorkspaceForUser } from "@/lib/workspace";
import ContactTags from "@/components/contact-tags";

const PAGE_SIZE = 50;

export default async function ContactsPage() {
const session = await auth();

if (!session?.user?.id) {
redirect("/login");
}

const workspace = await ensureWorkspaceForUser(
session.user.id,
session.user.email
);

const contacts = await prisma.contact.findMany({
where: { workspaceId: workspace.id },
orderBy: { updatedAt: "desc" },
take: PAGE_SIZE,
});

if (contacts.length === 0) {
return (
<div className="panel rounded p-8 text-center sm:p-12">
<h3 className="text-lg font-semibold mb-2">No contacts yet</h3>
<p className="mx-auto max-w-sm text-sm text-muted">
Todavía no hay contactos — aparecen automáticamente cuando alguien
te escribe o comenta.
</p>
</div>
);
}

return (
<div className="space-y-6">
<p className="text-sm text-muted">
{contacts.length} contact{contacts.length !== 1 ? "s" : ""}
</p>
<div className="space-y-3">
{contacts.map((contact) => (
<ContactTags
key={contact.id}
contactId={contact.id}
username={contact.username}
igUserId={contact.igUserId}
tags={contact.tags}
/>
))}
</div>
</div>
);
}
99 changes: 98 additions & 1 deletion app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,20 @@ interface WorkspaceMembersData {
}>;
}

interface EcommerceSettings {
slug: string | null;
hasSecret: boolean;
webhookUrl: string;
}

export default function SettingsPage() {
const [data, setData] = useState<SettingsData | null>(null);
const [membersData, setMembersData] = useState<WorkspaceMembersData | null>(
null
);
const [ecommerce, setEcommerce] = useState<EcommerceSettings | null>(null);
const [ecommerceSlugInput, setEcommerceSlugInput] = useState("");
const [revealedSecret, setRevealedSecret] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
const [inviteEmail, setInviteEmail] = useState("");
Expand All @@ -60,14 +69,43 @@ export default function SettingsPage() {
Promise.all([
fetch("/api/dashboard/stats").then((res) => res.json()),
fetch("/api/workspace/members").then((res) => res.json()),
fetch("/api/settings/ecommerce").then((res) => res.json()),
])
.then(([statsPayload, membersPayload]) => {
.then(([statsPayload, membersPayload, ecommercePayload]) => {
if (statsPayload.success) setData(statsPayload.data);
if (membersPayload.success) setMembersData(membersPayload.data);
if (ecommercePayload.success) {
setEcommerce(ecommercePayload.data);
setEcommerceSlugInput(ecommercePayload.data.slug ?? "");
}
})
.finally(() => setLoading(false));
}, []);

async function generateEcommerceSecret() {
setBusy("ecommerce");
setRevealedSecret(null);
const res = await fetch("/api/settings/ecommerce", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
ecommerceSlugInput.trim() ? { slug: ecommerceSlugInput.trim() } : {}
),
});
const payload = await res.json();
setBusy(null);
if (payload.success) {
setRevealedSecret(payload.data.secret);
setEcommerce((prev) =>
prev
? { ...prev, hasSecret: true, slug: ecommerceSlugInput.trim() || prev.slug }
: prev
);
} else {
alert(payload.error ?? "Could not generate secret");
}
}

async function refreshMembers() {
const res = await fetch("/api/workspace/members");
const payload = await res.json();
Expand Down Expand Up @@ -336,6 +374,65 @@ export default function SettingsPage() {
</span>
</div>
</section>

<section className="panel rounded p-4 sm:p-6">
<h2 className="text-base font-semibold mb-1">Ecommerce integration</h2>
<p className="text-xs text-muted mb-6">
Connect a BeCommerce (Medusa) store to log its order events here.
</p>

<div className="space-y-3 max-w-md">
<div>
<label className="block text-xs text-muted mb-1">
Store slug
</label>
<input
value={ecommerceSlugInput}
onChange={(e) => setEcommerceSlugInput(e.target.value)}
placeholder="your-store-slug"
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-foreground placeholder:text-zinc-500 focus:border-accent/40 focus:outline-none"
/>
</div>

<div>
<label className="block text-xs text-muted mb-1">
Webhook URL
</label>
<input
readOnly
value={ecommerce?.webhookUrl ?? ""}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-muted"
/>
</div>

<button
type="button"
onClick={generateEcommerceSecret}
disabled={busy === "ecommerce" || !ecommerceSlugInput.trim()}
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white disabled:opacity-50"
>
{ecommerce?.hasSecret ? "Regenerate secret" : "Generate secret"}
</button>

{revealedSecret && (
<div className="rounded-lg border border-border bg-surface p-3">
<p className="text-xs text-muted mb-1">
Copy this now — it won&apos;t be shown again. Set it as
BEREPLY_WEBHOOK_SECRET on the BeCommerce side.
</p>
<code className="block break-all text-xs text-foreground">
{revealedSecret}
</code>
</div>
)}

{!revealedSecret && ecommerce?.hasSecret && (
<p className="text-xs text-muted">
A secret is already set. Regenerating replaces it immediately.
</p>
)}
</div>
</section>
</div>
);
}
149 changes: 149 additions & 0 deletions app/api/automations/[id]/flow/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { Prisma } from "@/app/generated/prisma/client";
import { getCurrentWorkspaceId } from "@/lib/auth";
import { prisma } from "@/lib/db/client";
import {
FLOW_NODE_TYPES,
parseFlowEdges,
parseFlowNodes,
} from "@/lib/flow/engine";
import {
canManageWorkspace,
getCurrentWorkspaceContext,
} from "@/lib/workspace-access";

// The builder reads back what it just wrote, so never cache this.
export const dynamic = "force-dynamic";

type RouteProps = { params: Promise<{ id: string }> };

const flowNodeSchema = z.object({
id: z.string().min(1).max(64),
type: z.enum(FLOW_NODE_TYPES),
data: z.record(z.string(), z.unknown()),
position: z.object({ x: z.number(), y: z.number() }),
});

const flowEdgeSchema = z.object({
id: z.string().min(1).max(128),
source: z.string().min(1).max(64),
sourceHandle: z.string().min(1).max(32).optional(),
target: z.string().min(1).max(64),
});

const flowSchema = z.object({
nodes: z.array(flowNodeSchema).max(100),
edges: z.array(flowEdgeSchema).max(200),
});

export async function GET(_request: NextRequest, { params }: RouteProps) {
const workspaceId = await getCurrentWorkspaceId();
if (!workspaceId) {
return NextResponse.json(
{ success: false, error: "Unauthorized" },
{ status: 401 }
);
}

const { id } = await params;
const automation = await prisma.automation.findFirst({
where: { id, workspaceId },
select: { nodes: true, edges: true },
});

if (!automation) {
return NextResponse.json(
{ success: false, error: "Campaign not found" },
{ status: 404 }
);
}

return NextResponse.json(
{
success: true,
data: {
nodes: parseFlowNodes(automation.nodes),
edges: parseFlowEdges(automation.edges),
},
},
{ headers: { "Cache-Control": "no-store" } }
);
}

export async function PUT(request: NextRequest, { params }: RouteProps) {
const context = await getCurrentWorkspaceContext();
if (!context) {
return NextResponse.json(
{ success: false, error: "Unauthorized" },
{ status: 401 }
);
}

if (!canManageWorkspace(context.role)) {
return NextResponse.json(
{ success: false, error: "Only owners and admins can edit campaigns" },
{ status: 403 }
);
}

const { id } = await params;
const body = await request.json();
const parsed = flowSchema.safeParse(body);

if (!parsed.success) {
return NextResponse.json(
{
success: false,
error: "Invalid input",
details: parsed.error.flatten(),
},
{ status: 400 }
);
}

const existing = await prisma.automation.findFirst({
where: { id, workspaceId: context.workspaceId },
select: { id: true },
});

if (!existing) {
return NextResponse.json(
{ success: false, error: "Campaign not found" },
{ status: 404 }
);
}

const nodeIds = new Set(parsed.data.nodes.map((node) => node.id));
const danglingEdge = parsed.data.edges.find(
(edge) => !nodeIds.has(edge.source) || !nodeIds.has(edge.target)
);
if (danglingEdge) {
return NextResponse.json(
{ success: false, error: "An edge points at a node that is not in the flow" },
{ status: 400 }
);
}

// An empty canvas clears the flow instead of storing `[]`: a present-but-empty
// flow would leave the campaign doing nothing, while null keeps the worker on
// the legacy keyword/dmMessage path.
const isEmpty = parsed.data.nodes.length === 0;

await prisma.automation.update({
where: { id },
data: {
nodes: isEmpty
? Prisma.DbNull
: (parsed.data.nodes as unknown as Prisma.InputJsonValue),
edges: isEmpty
? Prisma.DbNull
: (parsed.data.edges as unknown as Prisma.InputJsonValue),
},
});

return NextResponse.json({
success: true,
data: { nodes: parsed.data.nodes, edges: parsed.data.edges },
});
}
Loading