diff --git a/app/(dashboard)/campaigns/[id]/flow/page.tsx b/app/(dashboard)/campaigns/[id]/flow/page.tsx new file mode 100644 index 00000000..e007b4c7 --- /dev/null +++ b/app/(dashboard)/campaigns/[id]/flow/page.tsx @@ -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 ( +
+
+ + ← {automation.name} + +

Flow

+

+ {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."} +

+
+ + +
+ ); +} diff --git a/app/(dashboard)/contacts/page.tsx b/app/(dashboard)/contacts/page.tsx new file mode 100644 index 00000000..1911a468 --- /dev/null +++ b/app/(dashboard)/contacts/page.tsx @@ -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 ( +
+

No contacts yet

+

+ Todavía no hay contactos — aparecen automáticamente cuando alguien + te escribe o comenta. +

+
+ ); + } + + return ( +
+

+ {contacts.length} contact{contacts.length !== 1 ? "s" : ""} +

+
+ {contacts.map((contact) => ( + + ))} +
+
+ ); +} diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index d523e55b..cf8207be 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -45,11 +45,20 @@ interface WorkspaceMembersData { }>; } +interface EcommerceSettings { + slug: string | null; + hasSecret: boolean; + webhookUrl: string; +} + export default function SettingsPage() { const [data, setData] = useState(null); const [membersData, setMembersData] = useState( null ); + const [ecommerce, setEcommerce] = useState(null); + const [ecommerceSlugInput, setEcommerceSlugInput] = useState(""); + const [revealedSecret, setRevealedSecret] = useState(null); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(null); const [inviteEmail, setInviteEmail] = useState(""); @@ -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(); @@ -336,6 +374,65 @@ export default function SettingsPage() { + +
+

Ecommerce integration

+

+ Connect a BeCommerce (Medusa) store to log its order events here. +

+ +
+
+ + 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" + /> +
+ +
+ + +
+ + + + {revealedSecret && ( +
+

+ Copy this now — it won't be shown again. Set it as + BEREPLY_WEBHOOK_SECRET on the BeCommerce side. +

+ + {revealedSecret} + +
+ )} + + {!revealedSecret && ecommerce?.hasSecret && ( +

+ A secret is already set. Regenerating replaces it immediately. +

+ )} +
+
); } diff --git a/app/api/automations/[id]/flow/route.ts b/app/api/automations/[id]/flow/route.ts new file mode 100644 index 00000000..bdf7f4f9 --- /dev/null +++ b/app/api/automations/[id]/flow/route.ts @@ -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 }, + }); +} diff --git a/app/api/automations/route.ts b/app/api/automations/route.ts index 0beea465..e09e2934 100644 --- a/app/api/automations/route.ts +++ b/app/api/automations/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; import { getCurrentWorkspaceId } from "@/lib/auth"; import { prisma } from "@/lib/db/client"; +import { Prisma } from "@/app/generated/prisma/client"; import { calculateCtr, normalizeTopKeywords } from "@/lib/tracking/analytics"; import { buildTrackedUrl } from "@/lib/tracking/message"; import { generateTrackedLinkSlug } from "@/lib/tracking/server"; @@ -60,6 +61,11 @@ const createAutomationSchema = z secondaryButtonLabel: z.string().max(20).optional().nullable(), isActive: z.boolean().optional().default(true), wholeWordMatch: z.boolean().optional().default(true), + aiEnabled: z.boolean().optional().default(false), + aiConfig: z + .object({ systemPrompt: z.string().max(4000) }) + .optional() + .nullable(), }) // A campaign must target a specific post, any post, or the next reel. .refine( @@ -106,6 +112,11 @@ const updateAutomationSchema = z.object({ publicReplyMessages: z.array(z.string().max(1000)).max(10).optional(), isActive: z.boolean().optional(), wholeWordMatch: z.boolean().optional(), + aiEnabled: z.boolean().optional(), + aiConfig: z + .object({ systemPrompt: z.string().max(4000) }) + .optional() + .nullable(), reportShareEnabled: z.boolean().optional(), // Empty string clears the tracked link; a URL updates/creates it; undefined // leaves it unchanged. @@ -427,6 +438,8 @@ export async function POST(request: NextRequest) { : null, isActive: parsed.data.isActive, wholeWordMatch: parsed.data.wholeWordMatch, + aiEnabled: parsed.data.aiEnabled, + aiConfig: parsed.data.aiConfig ?? undefined, workspaceId, instagramAccountId: instagramAccount.id, reportShareSlug: generateReportShareSlug(), @@ -538,7 +551,13 @@ export async function PATCH(request: NextRequest) { const updated = await prisma.automation.update({ where: { id: automationId }, - data: automationData, + data: { + ...automationData, + aiConfig: + automationData.aiConfig === null + ? Prisma.JsonNull + : automationData.aiConfig, + }, }); // Update, create, or clear the campaign's primary tracked link when a diff --git a/app/api/contacts/[id]/tags/route.ts b/app/api/contacts/[id]/tags/route.ts new file mode 100644 index 00000000..10382aac --- /dev/null +++ b/app/api/contacts/[id]/tags/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCurrentWorkspaceId } from "@/lib/auth"; +import { prisma } from "@/lib/db/client"; +import { addTag, removeTag } from "@/lib/contacts"; + +type RouteProps = { params: Promise<{ id: string }> }; + +const tagSchema = z.object({ + tag: z.string().trim().min(1).max(50), +}); + +async function requireOwnedContact(workspaceId: string, contactId: string) { + return prisma.contact.findFirst({ + where: { id: contactId, workspaceId }, + select: { id: true }, + }); +} + +export async function POST(request: NextRequest, { params }: RouteProps) { + const workspaceId = await getCurrentWorkspaceId(); + if (!workspaceId) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const parsed = tagSchema.safeParse(await request.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json( + { success: false, error: "Invalid tag" }, + { status: 400 } + ); + } + + const { id } = await params; + const contact = await requireOwnedContact(workspaceId, id); + if (!contact) { + return NextResponse.json( + { success: false, error: "Contact not found" }, + { status: 404 } + ); + } + + const updated = await addTag(id, parsed.data.tag); + return NextResponse.json({ success: true, data: updated }); +} + +export async function DELETE(request: NextRequest, { params }: RouteProps) { + const workspaceId = await getCurrentWorkspaceId(); + if (!workspaceId) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const parsed = tagSchema.safeParse(await request.json().catch(() => ({}))); + if (!parsed.success) { + return NextResponse.json( + { success: false, error: "Invalid tag" }, + { status: 400 } + ); + } + + const { id } = await params; + const contact = await requireOwnedContact(workspaceId, id); + if (!contact) { + return NextResponse.json( + { success: false, error: "Contact not found" }, + { status: 404 } + ); + } + + const updated = await removeTag(id, parsed.data.tag); + return NextResponse.json({ success: true, data: updated }); +} diff --git a/app/api/contacts/route.ts b/app/api/contacts/route.ts new file mode 100644 index 00000000..8a36581a --- /dev/null +++ b/app/api/contacts/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCurrentWorkspaceId } from "@/lib/auth"; +import { prisma } from "@/lib/db/client"; + +// Tags are edited from this same list, so a stale cached read would show a +// contact's old tags right after the user just changed them. +export const dynamic = "force-dynamic"; + +const PAGE_SIZE = 50; + +export async function GET(request: NextRequest) { + const workspaceId = await getCurrentWorkspaceId(); + if (!workspaceId) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const cursor = request.nextUrl.searchParams.get("cursor"); + + const contacts = await prisma.contact.findMany({ + where: { workspaceId }, + orderBy: { updatedAt: "desc" }, + take: PAGE_SIZE, + ...(cursor ? { skip: 1, cursor: { id: cursor } } : {}), + }); + + const nextCursor = + contacts.length === PAGE_SIZE ? contacts[contacts.length - 1].id : null; + + return NextResponse.json({ + success: true, + data: { contacts, nextCursor }, + }); +} diff --git a/app/api/settings/ecommerce/route.ts b/app/api/settings/ecommerce/route.ts new file mode 100644 index 00000000..0b8d64d7 --- /dev/null +++ b/app/api/settings/ecommerce/route.ts @@ -0,0 +1,93 @@ +import { NextRequest, NextResponse } from "next/server"; +import crypto from "crypto"; +import { + getCurrentWorkspaceContext, + canManageWorkspace, +} from "@/lib/workspace-access"; +import { prisma } from "@/lib/db/client"; +import { getBaseUrl } from "@/lib/env"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + const context = await getCurrentWorkspaceContext(); + if (!context) { + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 } + ); + } + + const workspace = await prisma.workspace.findUnique({ + where: { id: context.workspaceId }, + select: { ecommerceStoreSlug: true, ecommerceWebhookSecret: true }, + }); + + return NextResponse.json({ + success: true, + data: { + slug: workspace?.ecommerceStoreSlug ?? null, + hasSecret: Boolean(workspace?.ecommerceWebhookSecret), + webhookUrl: `${getBaseUrl()}/api/webhooks/ecommerce`, + }, + }); +} + +/** + * Sets the BeCommerce store slug and/or (re)generates the shared secret. + * The secret is returned exactly once, here — same pattern as an API key + * generator. Losing it means generating a new one, not recovering the old. + */ +export async function POST(request: NextRequest) { + 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 change this" }, + { status: 403 } + ); + } + + const body = await request.json().catch(() => ({})); + const slug = + typeof body?.slug === "string" && body.slug.trim() + ? body.slug.trim() + : undefined; + + const secret = crypto.randomBytes(32).toString("hex"); + + try { + await prisma.workspace.update({ + where: { id: context.workspaceId }, + data: { + ...(slug ? { ecommerceStoreSlug: slug } : {}), + ecommerceWebhookSecret: secret, + }, + }); + } catch (error: unknown) { + // Unique constraint on ecommerceStoreSlug — another workspace already + // claimed it. + if ( + typeof error === "object" && + error !== null && + "code" in error && + (error as { code?: string }).code === "P2002" + ) { + return NextResponse.json( + { success: false, error: "That store slug is already in use" }, + { status: 409 } + ); + } + throw error; + } + + return NextResponse.json({ + success: true, + data: { secret, webhookUrl: `${getBaseUrl()}/api/webhooks/ecommerce` }, + }); +} diff --git a/app/api/webhooks/ecommerce/route.ts b/app/api/webhooks/ecommerce/route.ts new file mode 100644 index 00000000..782ab453 --- /dev/null +++ b/app/api/webhooks/ecommerce/route.ts @@ -0,0 +1,121 @@ +import { createHmac, timingSafeEqual } from "crypto"; +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db/client"; +import { Prisma } from "@/app/generated/prisma/client"; + +interface EcommerceOrderWebhookPayload { + event_type: string; + data: { + slug: string; + order_id: string; + display_id: number; + email: string; + name: string; + items: Array<{ title: string; quantity: number; unit_price: number }>; + total: number; + currency: string; + timestamp: string; + tags?: string[]; + }; +} + +function isEcommerceOrderWebhookPayload( + value: unknown +): value is EcommerceOrderWebhookPayload { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + if (typeof record.event_type !== "string") return false; + + const data = record.data; + if (typeof data !== "object" || data === null) return false; + + return typeof (data as Record).slug === "string"; +} + +// Same HMAC-over-raw-body pattern already proven in prod for the Maasy CRM +// integration. Compare hex digests as fixed-length buffers so a length +// mismatch short-circuits before timingSafeEqual (which throws on unequal +// lengths) instead of leaking timing information. +function isValidSignature( + rawBody: string, + signatureHeader: string | null, + secret: string +): boolean { + if (!signatureHeader) return false; + + const prefix = "sha256="; + if (!signatureHeader.startsWith(prefix)) return false; + const providedHex = signatureHeader.slice(prefix.length); + + const expectedHex = createHmac("sha256", secret) + .update(rawBody) + .digest("hex"); + + const provided = Buffer.from(providedHex, "utf8"); + const expected = Buffer.from(expectedHex, "utf8"); + if (provided.length !== expected.length) return false; + + return timingSafeEqual(provided, expected); +} + +export async function POST(request: NextRequest) { + try { + const rawBody = await request.text(); + + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch { + return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); + } + + if (!isEcommerceOrderWebhookPayload(payload)) { + return NextResponse.json({ error: "Invalid payload" }, { status: 400 }); + } + + const workspace = await prisma.workspace.findUnique({ + where: { ecommerceStoreSlug: payload.data.slug }, + select: { id: true, ecommerceWebhookSecret: true }, + }); + + if (!workspace) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + + if (!workspace.ecommerceWebhookSecret) { + return NextResponse.json( + { error: "integration not configured" }, + { status: 400 } + ); + } + + const signatureHeader = request.headers.get("X-Webhook-Signature"); + if ( + !isValidSignature( + rawBody, + signatureHeader, + workspace.ecommerceWebhookSecret + ) + ) { + return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + } + + // Log-only by design: there is no way today to link a BeCommerce buyer + // email to an Instagram Contact (igUserId), so this intentionally does + // NOT attempt any matching/auto-tagging. It exists for audit trail and + // future features once that link exists. + await prisma.webhookEvent.create({ + data: { + workspaceId: workspace.id, + object: "ecommerce_order", + payload: payload as unknown as Prisma.InputJsonValue, + status: "PENDING", + }, + }); + + return NextResponse.json({ received: true }, { status: 200 }); + } catch (error) { + console.error("ecommerce webhook error", error); + return NextResponse.json({ error: "Internal error" }, { status: 500 }); + } +} diff --git a/app/apple-icon.tsx b/app/apple-icon.tsx new file mode 100644 index 00000000..2c6bd9e9 --- /dev/null +++ b/app/apple-icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from "next/og"; + +export const size = { width: 180, height: 180 }; +export const contentType = "image/png"; + +export default function AppleIcon() { + return new ImageResponse( + ( +
+ B +
+ ), + size + ); +} diff --git a/app/globals.css b/app/globals.css index ffd70164..fe34d3fc 100644 --- a/app/globals.css +++ b/app/globals.css @@ -11,8 +11,8 @@ --color-border: #e4e4e7; --color-border-hover: #d4d4d8; --color-muted: #71717a; - --color-accent: #f97316; - --color-accent-hover: #ea580c; + --color-accent: #0c3bb9; + --color-accent-hover: #062778; --color-success: #16a34a; --color-error: #dc2626; --color-warning: #d97706; diff --git a/app/icon-192/route.tsx b/app/icon-192/route.tsx new file mode 100644 index 00000000..abe5f286 --- /dev/null +++ b/app/icon-192/route.tsx @@ -0,0 +1,25 @@ +import { ImageResponse } from "next/og"; + +export function GET() { + return new ImageResponse( + ( +
+ B +
+ ), + { width: 192, height: 192 } + ); +} diff --git a/app/icon-512/route.tsx b/app/icon-512/route.tsx new file mode 100644 index 00000000..3d0c4657 --- /dev/null +++ b/app/icon-512/route.tsx @@ -0,0 +1,25 @@ +import { ImageResponse } from "next/og"; + +export function GET() { + return new ImageResponse( + ( +
+ B +
+ ), + { width: 512, height: 512 } + ); +} diff --git a/app/icon.tsx b/app/icon.tsx new file mode 100644 index 00000000..9d5f5d5e --- /dev/null +++ b/app/icon.tsx @@ -0,0 +1,28 @@ +import { ImageResponse } from "next/og"; + +export const size = { width: 32, height: 32 }; +export const contentType = "image/png"; + +export default function Icon() { + return new ImageResponse( + ( +
+ B +
+ ), + size + ); +} diff --git a/app/invite/[token]/page.tsx b/app/invite/[token]/page.tsx index 78dd9b62..3ad5ddf5 100644 --- a/app/invite/[token]/page.tsx +++ b/app/invite/[token]/page.tsx @@ -4,13 +4,14 @@ import { notFound } from "next/navigation"; import InvitationAcceptCard from "@/components/invitation-accept-card"; import { auth } from "@/lib/auth"; import { prisma } from "@/lib/db/client"; +import { BrandMark } from "@/components/brand-mark"; type InvitePageProps = { params: Promise<{ token: string }>; }; export const metadata: Metadata = { - title: "Accept Workspace Invitation - OpenReply", + title: "Accept Workspace Invitation - BeReply", robots: { index: false, follow: false }, }; @@ -35,8 +36,8 @@ export default async function InvitePage({ params }: InvitePageProps) { return (
- - OpenReply + +

diff --git a/app/layout.tsx b/app/layout.tsx index 07a39858..2e09b1f1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,31 +1,30 @@ import type { Metadata, Viewport } from "next"; +import { Fraunces } from "next/font/google"; import { Analytics } from "@vercel/analytics/next"; import "./globals.css"; +const fraunces = Fraunces({ + subsets: ["latin"], + weight: "500", + variable: "--font-display", +}); + export const metadata: Metadata = { - title: "OpenReply - Open source Instagram comment-to-DM automation", + title: "BeReply - Instagram comment-to-DM automation", description: - "A free, self-hosted ManyChat alternative. Send an Instagram DM automatically when someone comments a keyword on your post or reel, using the official Meta API.", + "Send an Instagram DM automatically when someone comments a keyword on your post or reel, using the official Meta API.", keywords: [ "instagram automation", "comment to DM", "instagram private replies", "social commerce", - "manychat alternative", ], manifest: "/manifest.webmanifest", appleWebApp: { capable: true, - title: "OpenReply", + title: "BeReply", statusBarStyle: "black-translucent", }, - icons: { - icon: [ - { url: "/icon-192.png", sizes: "192x192", type: "image/png" }, - { url: "/icon-512.png", sizes: "512x512", type: "image/png" }, - ], - apple: "/apple-touch-icon.png", - }, }; export const viewport: Viewport = { @@ -43,7 +42,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - +

-

- OpenReply +

+

{selectedTemplate diff --git a/app/manifest.ts b/app/manifest.ts index 31556a60..f7eb7a5c 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -6,18 +6,18 @@ import type { MetadataRoute } from "next"; // a phone practical. export default function manifest(): MetadataRoute.Manifest { return { - name: "OpenReply", - short_name: "OpenReply", + name: "BeReply", + short_name: "BeReply", description: "Instagram comment-to-DM automation", start_url: "/overview", display: "standalone", orientation: "portrait", - background_color: "#18181b", - theme_color: "#18181b", + background_color: "#050C29", + theme_color: "#0c3bb9", icons: [ - { src: "/icon-192.png", sizes: "192x192", type: "image/png", purpose: "any" }, - { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "any" }, - { src: "/icon-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" }, + { src: "/icon-192", sizes: "192x192", type: "image/png", purpose: "any" }, + { src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "any" }, + { src: "/icon-512", sizes: "512x512", type: "image/png", purpose: "maskable" }, ], }; } diff --git a/app/page.tsx b/app/page.tsx index 2276552a..73bcb0cd 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,508 +1,7 @@ -import type { Metadata } from "next"; -import type { ReactNode } from "react"; -import Link from "next/link"; -import { DemoNotice } from "@/components/demo-notice"; +import { redirect } from "next/navigation"; +import { auth } from "@/lib/auth"; -export const metadata: Metadata = { - title: "OpenReply - Open source Instagram comment-to-DM automation", - description: - "A free, self-hosted ManyChat alternative. Turn Instagram keyword comments into automatic private replies using the official Meta API.", -}; - -const GITHUB_URL = "https://github.com/diwenne/openreply"; -const SETUP_DOCS_URL = - "https://github.com/diwenne/openreply/blob/main/docs/setup.md"; - -function formatStars(count: number): string { - if (count >= 1000) { - return `${(count / 1000).toFixed(1)}K`; - } - return count.toLocaleString(); -} - -const githubIconPath = - "M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0016 8c0-4.42-3.58-8-8-8z"; - -const heroStats = [ - { value: "24/7", label: "Comment monitoring" }, - { value: "1", label: "DM per matched comment" }, - { value: "0", label: "Scraping required" }, -]; - -const flowSteps = [ - { - eyebrow: "Connect", - title: "Link your Instagram professional account", - description: - "Sign in by email and connect Instagram once. No password sharing, no browser automation.", - }, - { - eyebrow: "Build", - title: "Pick a post, keywords, and the DM", - description: - "Create a campaign for a reel or post: the keyword to watch, the public reply, and the DM to send.", - }, - { - eyebrow: "Deliver", - title: "Replies go out through the official API", - description: - "Webhooks catch comments instantly and a polling sweep catches the ones Instagram never pushes, so nothing is missed. Every send is queued, rate-limited, and logged.", - }, -]; - -const features = [ - "Email magic-link sign-in", - "Multiple Instagram accounts", - "Encrypted tokens at rest", - "Webhook + polling reconciliation", - "Queue-backed delivery worker", - "Per-account rate limiting", - "Tracked links with click stats", - "DM logs with full status", - "No plan limits, fully self-hosted", -]; - -/* Static, faithful copies of the real Overview and Dashboard screens, built in - the app's own design tokens so what visitors see is what the app looks like. */ - -function AppWindow({ label, children }: { label: string; children: ReactNode }) { - return ( -

-
- - - - {label} -
-
{children}
-
- ); -} - -function Stat({ label, value }: { label: string; value: string }) { - return ( -
-

{label}

-

{value}

-
- ); -} - -const overviewStats = [ - ["Views", "847.2K"], - ["Reach", "612.4K"], - ["Likes", "38.1K"], - ["Comments", "4,204"], - ["Saved", "9,712"], - ["Shares", "2,340"], -]; - -const overviewPosts = [ - ["Spring drop reel", "214.8K", "9.1K", "Apr 3"], - ["Restock haul", "88.4K", "5.2K", "Mar 28"], - ["Behind the studio", "51.3K", "3.4K", "Mar 21"], -]; - -function OverviewPreview() { - return ( - -
-
-

Overview

-

- Recent — 24 posts from @studio.store -

-
- - Last 50 - -
- -
- {overviewStats.map(([label, value]) => ( - - ))} -
- -
-
-

- Followers over time -

-

- 48,210 +1,240 · 30d -

-
- -
- -
-

Posts

- - - - - - - - - - - {overviewPosts.map(([post, views, likes, date]) => ( - - - - - - - ))} - -
PostViewsLikesDate
{post}{views}{likes}{date}
-
-
- ); -} - -function MatchedCommentCard() { - return ( -
-

New comment

-

@maya.co

-

LINK please

-
-

- Matched GUIDE -

-

- Queued private reply -

-
-
- ); -} - -const dashboardStats = [ - ["Active Campaigns", "8"], - ["DMs Sent", "1,284"], - ["Skipped", "42"], - ["Failed", "3"], - ["Clicks", "356"], - ["CTR", "27.7%"], -]; - -const dashboardChart: [string, number][] = [ - ["Mon", 42], - ["Tue", 68], - ["Wed", 51], - ["Thu", 94], - ["Fri", 120], - ["Sat", 86], - ["Sun", 73], -]; - -const dashboardActivity = [ - ["@maya.co", "Product guide reply", "Sent", "text-success"], - ["@founder.ray", "Price request", "Sent", "text-success"], - ["@shop.ava", "Lead magnet", "Queued", "text-warning"], -]; - -function DashboardPreview() { - const maxDM = Math.max(...dashboardChart.map(([, n]) => n)); - return ( - -

Hello, Maya!

-

2 connected accounts · 340 contacts

- -
- {dashboardStats.map(([label, value]) => ( - - ))} -
- -
-

DMs — Last 7 Days

-
- {dashboardChart.map(([day, n]) => ( -
- {n} -
- {day} -
- ))} -
-
- -
-

Recent Activity

-
- {dashboardActivity.map(([user, automation, status, color]) => ( -
- {user} - {automation} - {status} -
- ))} -
-
- - ); -} - -async function getGitHubStars(): Promise { - try { - const res = await fetch("https://api.github.com/repos/diwenne/openreply", { - headers: { Accept: "application/vnd.github+json" }, - next: { revalidate: 3600 }, - }); - if (!res.ok) return null; - const data = (await res.json()) as { stargazers_count?: number }; - return typeof data.stargazers_count === "number" ? data.stargazers_count : null; - } catch { - return null; - } -} - -export default async function Home() { - const stars = await getGitHubStars(); - return ( -
- - -
-
- - OpenReply - - - -
-
- -
-
-
- Open source · Official Meta API -
- -

- Make every comment start the right DM -

- -

- Open-sourced ManyChat. When someone comments your keyword on a post - or reel, they get your DM a second later. Free, self-hosted, and - built on the official Instagram API. -

- -
- - Get started - - - See how it works - -
- -
- {heroStats.map((stat) => ( -
-
{stat.value}
-
{stat.label}
-
- ))} -
-
- -
- -
- -
-
-
- -
-
-
-

How it works

-

- A comment in, a DM out -

-

- Three steps. Connect an account, build a campaign, and let it run. - The webhook handles it live and the poll sweeps up whatever the - webhook misses. -

-
- -
- {flowSteps.map((step) => ( -
-

{step.eyebrow}

-
-

{step.title}

-

{step.description}

-
-
- ))} -
-
-
- -
-
- - -
-

The dashboard

-

- See exactly what happened -

-

- Every comment event is traceable: queued, matched, sent, skipped, - failed, or rate-limited. No black box. -

-
-
-
- -
-
-

What’s included

-

- Everything, no tiers -

-

- It is self-hosted and open source, so there is nothing to unlock. You - run it, you own it. -

-
- -
- {features.map((feature) => ( -
- {feature} -
- ))} -
-
- -
-
-
-

- Turn your next reel’s comments into DMs -

-

- Free and open source. Star it if it saves you a subscription. -

-

- - On your own deployment, not this one. - {" "} - Clone the repo and follow the{" "} - - setup guide - {" "} - — a Meta app and a domain of your own are required before anything - sends. -

-
-
- - Get started - - - View on GitHub - -
-
-
- - -
- ); +export default async function RootPage() { + const session = await auth(); + redirect(session?.user?.id ? "/dashboard" : "/login"); } diff --git a/app/verify-request/page.tsx b/app/verify-request/page.tsx index 8606c32a..c4d3e1a7 100644 --- a/app/verify-request/page.tsx +++ b/app/verify-request/page.tsx @@ -1,7 +1,8 @@ import Link from "next/link"; +import { BrandMark } from "@/components/brand-mark"; export const metadata = { - title: "Check your email - OpenReply", + title: "Check your email - BeReply", description: "A sign-in link was sent to your email.", }; @@ -10,8 +11,8 @@ export default function VerifyRequestPage() {
-

- OpenReply +

+

diff --git a/components/brand-mark.tsx b/components/brand-mark.tsx new file mode 100644 index 00000000..af1f53dc --- /dev/null +++ b/components/brand-mark.tsx @@ -0,0 +1,36 @@ +/** + * BeReply wordmark — Believe brand system §3: "Be" prefix in Cyan 400, + * descriptor in Believe Blue 700 (Paper 50 in the negative variant, for + * dark/blue surfaces), set in Fraunces 500. Closing dot is Cyan 400, + * the same signature device that closes the house "Believe." wordmark. + */ +export function BrandMark({ + className = "", + variant = "positive", +}: { + className?: string; + variant?: "positive" | "negative"; +}) { + const descriptorColor = variant === "negative" ? "#fafaf7" : "#0c3bb9"; + return ( + + Be + Reply + + ); +} diff --git a/components/campaign-ai-settings.tsx b/components/campaign-ai-settings.tsx new file mode 100644 index 00000000..32d12659 --- /dev/null +++ b/components/campaign-ai-settings.tsx @@ -0,0 +1,95 @@ +"use client"; + +/** + * Campaign AI Settings + * + * Toggle + brand system prompt for the AI auto-responder (lib/ai/responder.ts). + * Controlled component — no fetch, no persistence. The caller owns state and + * saving; this just reports the next { aiEnabled, aiConfig } on every change. + */ + +interface AiConfig { + systemPrompt?: string; +} + +interface CampaignAiSettingsProps { + aiEnabled: boolean; + aiConfig: AiConfig | null; + onChange: (next: { + aiEnabled: boolean; + aiConfig: { systemPrompt: string } | null; + }) => void; +} + +function Toggle({ on, onToggle }: { on: boolean; onToggle: () => void }) { + return ( + + ); +} + +export default function CampaignAiSettings({ + aiEnabled, + aiConfig, + onChange, +}: CampaignAiSettingsProps) { + const systemPrompt = aiConfig?.systemPrompt ?? ""; + + return ( +
+

AI

+
+
+
+ Responder con IA +

+ Genera cada respuesta con IA en base a la conversación, en vez de + enviar siempre el mismo mensaje. +

+
+ + onChange({ + aiEnabled: !aiEnabled, + aiConfig: { systemPrompt }, + }) + } + /> +
+ {aiEnabled && ( +
+