diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index e37c7382..2444d563 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -39,6 +39,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod Recent New Ads + Promote GitHub Blog ↗ {profile?.is_admin && Admin} diff --git a/app/(app)/promote/[id]/page.tsx b/app/(app)/promote/[id]/page.tsx new file mode 100644 index 00000000..17d73971 --- /dev/null +++ b/app/(app)/promote/[id]/page.tsx @@ -0,0 +1,185 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import { cadenceLabel } from "@/lib/promote/generatePitch"; +import { PromoteListActions } from "@/components/promote/list-actions"; +import { PromoteEditForm } from "@/components/promote/promote-edit-form"; +import { AddLinksForm } from "@/components/promote/add-links-form"; +import { LinkList } from "@/components/promote/link-list"; + +export const metadata = { title: "Promote list" }; + +type Props = { params: Promise<{ id: string }> }; + +export default async function PromoteDetailPage({ params }: Props) { + const { id } = await params; + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) notFound(); + + const { data: list } = await supabase + .from("promo_list") + .select("*") + .eq("id", id) + .eq("user_id", user.id) + .maybeSingle(); + if (!list) notFound(); + + const [{ data: links }, { data: posts }, { data: accounts }] = await Promise.all([ + supabase + .from("promo_link") + .select("id, url, title, angle, enabled, times_promoted, last_promoted_at, created_at") + .eq("list_id", id) + .order("created_at", { ascending: true }), + supabase + .from("promo_post") + .select("id, link_id, platform, body, status, error, credits_spent, posted_at, provider, external_post_id") + .eq("list_id", id) + .order("created_at", { ascending: false }) + .limit(50), + supabase + .from("sp_account") + .select("id, platform, handle") + .eq("user_id", user.id) + .eq("status", "active") + .order("platform"), + ]); + + const linkRows = (links ?? []) as Array<{ + id: string; + url: string; + title: string | null; + angle: string | null; + enabled: boolean; + times_promoted: number; + last_promoted_at: string | null; + created_at: string; + }>; + + const postRows = (posts ?? []) as Array<{ + id: string; + link_id: string; + platform: string; + body: string; + status: string; + error: string | null; + credits_spent: number; + posted_at: string | null; + provider: string | null; + external_post_id: string | null; + }>; + + const accountRows = (accounts ?? []) as Array<{ + id: string; + platform: string; + handle: string; + }>; + + const totalPosts = postRows.filter((p) => p.status === "posted").length; + const totalCredits = postRows.reduce((sum, p) => sum + (p.credits_spent ?? 0), 0); + + return ( +
+
+
+ + ← All lists + +

{list.name}

+
+ +
+ + {/* Status bar */} +
+ + {list.status} + + {list.pause_reason === "insufficient_credits" && ( + + Low credits — buy more + + )} + + {cadenceLabel(list.cadence_seconds)} · {list.post_mode} · {linkRows.length} link{linkRows.length !== 1 ? "s" : ""} · {totalPosts} posts · {totalCredits} credits spent + +
+ + {/* Edit form */} +
+

Settings

+ +
+ + {/* Links */} +
+
+

Links

+
+ +
+ +
+
+ + {/* Recent posts */} +
+

Recent posts

+ {postRows.length === 0 ? ( +

No posts yet. The first drip will fire shortly if the list is running.

+ ) : ( + + )} +
+
+ ); +} diff --git a/app/(app)/promote/accounts/page.tsx b/app/(app)/promote/accounts/page.tsx new file mode 100644 index 00000000..63975d7e --- /dev/null +++ b/app/(app)/promote/accounts/page.tsx @@ -0,0 +1,255 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { createClient } from "@/lib/supabase/server"; +import { + ConnectBlueskyForm, + ConnectDiscordForm, + ConnectTelegramForm, + ConnectViaCookiesForm, + DisconnectButton, +} from "@/app/(app)/projects/[id]/social/setup/form"; + +export const metadata = { title: "Promote · Accounts" }; + +type AccountRow = { + id: string; + platform: string; + handle: string; + status: string; + last_post_at: string | null; + consecutive_failures: number; +}; + +const PLATFORM_LOGIN_URLS: Record = { + reddit: "https://www.reddit.com/login", + facebook_page: "https://www.facebook.com/login", + threads: "https://www.threads.net/login", + instagram: "https://www.instagram.com/accounts/login/", + x: "https://x.com/login", + linkedin: "https://www.linkedin.com/login", +}; + +export default async function PromoteAccountsPage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) redirect("/login"); + + const { data: accounts } = await supabase + .from("sp_account") + .select( + "id, platform, handle, status, last_post_at, consecutive_failures", + ) + .eq("user_id", user.id) + .order("created_at", { ascending: false }); + + return ( +
+
+ + ← Promote + +

Social accounts

+

+ Connect your social accounts here. These are shared across Promote + and per-project Social features — connect once, use everywhere. +

+
+ + {/* Connected accounts */} +
+

Connected accounts

+ {!accounts || accounts.length === 0 ? ( +

+ No accounts yet. Connect one below. +

+ ) : ( +
    + {(accounts as AccountRow[]).map((a) => ( +
  • +
    +
    + {a.handle} + + {a.platform} + + + {a.status} + +
    + {a.last_post_at && ( +

    + Last post {new Date(a.last_post_at).toLocaleString()} +

    + )} + {a.status !== "active" && PLATFORM_LOGIN_URLS[a.platform] && ( +

    + Session expired.{" "} + + Log in to {a.platform} + {" "} + and re-export cookies below. +

    + )} +
    + +
  • + ))} +
+ )} +
+ + {/* Connect Bluesky */} +
+

Connect Bluesky

+

+ Generate an app password at{" "} + + bsky.app/settings/app-passwords + {" "} + and paste it below. +

+ +
+ + {/* Connect Reddit */} +
+

Connect Reddit

+

+ Export cookies from reddit.com using{" "} + + Cookie-Editor + + . +

+ +
+ + {/* Connect Threads */} +
+

Connect Threads

+

+ Export cookies from threads.net using{" "} + + Cookie-Editor + + . +

+ +
+ + {/* Connect Facebook Page */} +
+

Connect Facebook Page

+

+ Export cookies from facebook.com using{" "} + + Cookie-Editor + + . +

+ +
+ + {/* Connect Instagram */} +
+

Connect Instagram

+

+ Export cookies from instagram.com using{" "} + + Cookie-Editor + + . +

+ +
+ + {/* Connect X */} +
+

Connect X (Twitter)

+

+ Export cookies from x.com using{" "} + + Cookie-Editor + + . +

+ +
+ + {/* Connect LinkedIn */} +
+

Connect LinkedIn

+

+ Export cookies from linkedin.com using{" "} + + Cookie-Editor + + . +

+ +
+ + {/* Connect Mastodon */} +
+

Connect Mastodon

+

+ Export cookies from your Mastodon instance using{" "} + + Cookie-Editor + + . +

+ +
+ + {/* Connect Discord */} +
+

Connect Discord channel

+

+ In Discord: Edit Channel → Integrations → Webhooks → New Webhook → Copy URL. +

+ +
+ + {/* Connect Telegram */} +
+

Connect Telegram channel

+

+ Create a bot via{" "} + + @BotFather + {" "} + and add it as a channel admin. +

+ +
+
+ ); +} diff --git a/app/(app)/promote/new/page.tsx b/app/(app)/promote/new/page.tsx new file mode 100644 index 00000000..17d5c6dc --- /dev/null +++ b/app/(app)/promote/new/page.tsx @@ -0,0 +1,33 @@ +import { createClient } from "@/lib/supabase/server"; +import { PromoteForm } from "@/components/promote/promote-form"; + +export const metadata = { title: "New promote list" }; + +export default async function NewPromotePage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + let accounts: Array<{ id: string; platform: string; handle: string }> = []; + if (user) { + const { data } = await supabase + .from("sp_account") + .select("id, platform, handle") + .eq("user_id", user.id) + .eq("status", "active") + .order("platform"); + accounts = (data ?? []) as typeof accounts; + } + + return ( +
+

New promote list

+

+ Paste links and we'll write unique marketing pitches for each, dripped across your connected social accounts. +

+ + +
+ ); +} diff --git a/app/(app)/promote/page.tsx b/app/(app)/promote/page.tsx new file mode 100644 index 00000000..ee92d6be --- /dev/null +++ b/app/(app)/promote/page.tsx @@ -0,0 +1,180 @@ +import Link from "next/link"; +import { createClient } from "@/lib/supabase/server"; +import { PromoteListActions } from "@/components/promote/list-actions"; +import { cadenceLabel } from "@/lib/promote/generatePitch"; + +export const metadata = { title: "Promote" }; + +type ListRow = { + id: string; + name: string; + status: string; + cadence_seconds: number; + post_mode: string; + last_run_at: string | null; + pause_reason: string | null; + created_at: string; +}; + +type LinkCountRow = { list_id: string; count: number }; + +export default async function PromotePage() { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + let lists: ListRow[] = []; + const linkCountById = new Map(); + const postCountById = new Map(); + const creditsById = new Map(); + let accountCount = 0; + + if (user) { + const [{ data: listData }, { count: acctCount }] = await Promise.all([ + supabase + .from("promo_list") + .select("id, name, status, cadence_seconds, post_mode, last_run_at, pause_reason, created_at") + .order("created_at", { ascending: false }), + supabase + .from("sp_account") + .select("id", { count: "exact", head: true }) + .eq("user_id", user.id) + .eq("status", "active"), + ]); + lists = (listData as ListRow[]) ?? []; + accountCount = acctCount ?? 0; + + if (lists.length > 0) { + const listIds = lists.map((l) => l.id); + + // Fetch link counts per list + const { data: linkCounts } = await supabase + .from("promo_link") + .select("list_id") + .in("list_id", listIds); + for (const r of (linkCounts ?? []) as Array<{ list_id: string }>) { + linkCountById.set(r.list_id, (linkCountById.get(r.list_id) ?? 0) + 1); + } + + // Fetch post counts + credits per list + const { data: postStats } = await supabase + .from("promo_post") + .select("list_id, credits_spent") + .in("list_id", listIds) + .eq("status", "posted"); + for (const r of (postStats ?? []) as Array<{ list_id: string; credits_spent: number }>) { + postCountById.set(r.list_id, (postCountById.get(r.list_id) ?? 0) + 1); + creditsById.set(r.list_id, (creditsById.get(r.list_id) ?? 0) + (r.credits_spent ?? 0)); + } + } + } + + return ( +
+
+

Promote

+
+ + Manage accounts + + + New promote list + +
+
+

+ Paste links. We write a fresh pitch for each and drip them across all your connected accounts. +

+ + {accountCount === 0 && ( +
+ You have no connected social accounts.{" "} + + Connect an account + {" "} + to start promoting. +
+ )} + + {lists.length === 0 ? ( +
+ No promote lists yet.{" "} + + Create your first list + + . +
+ ) : ( +
    + {lists.map((l) => { + const links = linkCountById.get(l.id) ?? 0; + const posts = postCountById.get(l.id) ?? 0; + const credits = creditsById.get(l.id) ?? 0; + return ( +
  • +
    +
    + + {l.name} + +
    + {links} link{links !== 1 ? "s" : ""} · {cadenceLabel(l.cadence_seconds)} ·{" "} + {l.post_mode} +
    +
    +
    + + {l.status} + + {l.pause_reason === "insufficient_credits" && ( + + low credits + + )} + +
    +
    +
    + + + +
    +
  • + ); + })} +
+ )} +
+ ); +} + +function MiniStat({ label, value }: { label: string; value: string }) { + return ( + + {label}: + {value} + + ); +} diff --git a/app/actions/promote.ts b/app/actions/promote.ts new file mode 100644 index 00000000..39fafb7c --- /dev/null +++ b/app/actions/promote.ts @@ -0,0 +1,295 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { createClient } from "@/lib/supabase/server"; +import { serviceClient } from "@/lib/supabase/service"; +import { parseLinks, fetchLinkTitle } from "@/lib/promote/generatePitch"; + +type Ok> = { ok: true } & T; +type Err = { ok: false; error: string }; + +async function requireUser(): Promise< + { ok: true; userId: string; email: string } | Err +> { + const supabase = await createClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) return { ok: false, error: "Not signed in." }; + return { ok: true, userId: user.id, email: user.email ?? "" }; +} + +// -------- Create a promote list -------- +export async function createPromoteList(input: { + name: string; + links: string; + cadenceSeconds: number; + postMode: "trickle" | "burst"; + brandVoice: string; + targetAccountIds: string[] | null; +}): Promise | Err> { + const auth = await requireUser(); + if (!auth.ok) return auth; + + const name = (input.name ?? "").trim() || "Promote list"; + const urls = parseLinks(input.links); + if (urls.length === 0) { + return { ok: false, error: "Paste at least one valid URL." }; + } + if (urls.length > 200) { + return { ok: false, error: "Maximum 200 links per list." }; + } + + const cadence = input.cadenceSeconds; + if (cadence < 300 || cadence > 604800) { + return { ok: false, error: "Cadence must be between 5 minutes and 7 days." }; + } + + const svc = serviceClient(); + + // Create the list + const { data: list, error: listErr } = await svc + .from("promo_list") + .insert({ + user_id: auth.userId, + name, + status: "running", + cadence_seconds: cadence, + post_mode: input.postMode ?? "trickle", + brand_voice: input.brandVoice?.trim() || null, + target_account_ids: input.targetAccountIds, + next_run_at: new Date().toISOString(), + }) + .select("id") + .single(); + if (listErr || !list) { + return { ok: false, error: listErr?.message ?? "Could not create list." }; + } + + // Insert links (fetch titles in the background — don't block the response) + const linkRows = urls.map((url) => ({ + list_id: list.id, + url, + enabled: true, + })); + const { error: linksErr } = await svc.from("promo_link").insert(linkRows); + if (linksErr) { + // Clean up the list if links failed + await svc.from("promo_list").delete().eq("id", list.id); + return { ok: false, error: linksErr.message }; + } + + // Best-effort title fetching (non-blocking) + fetchTitlesForList(list.id, urls).catch(() => {}); + + revalidatePath("/promote"); + return { ok: true, listId: list.id as string }; +} + +// Background title fetcher +async function fetchTitlesForList(listId: string, urls: string[]) { + const svc = serviceClient(); + for (const url of urls.slice(0, 50)) { + try { + const title = await fetchLinkTitle(url); + if (title) { + await svc + .from("promo_link") + .update({ title }) + .eq("list_id", listId) + .eq("url", url); + } + } catch { + // ignore + } + } +} + +// -------- Update a promote list -------- +export async function updatePromoteList(input: { + listId: string; + name: string; + cadenceSeconds: number; + postMode: "trickle" | "burst"; + brandVoice: string; + targetAccountIds: string[] | null; +}): Promise { + const auth = await requireUser(); + if (!auth.ok) return auth; + + const svc = serviceClient(); + const { error } = await svc + .from("promo_list") + .update({ + name: input.name.trim() || "Promote list", + cadence_seconds: input.cadenceSeconds, + post_mode: input.postMode ?? "trickle", + brand_voice: input.brandVoice?.trim() || null, + target_account_ids: input.targetAccountIds, + }) + .eq("id", input.listId) + .eq("user_id", auth.userId); + if (error) return { ok: false, error: error.message }; + + revalidatePath("/promote"); + revalidatePath(`/promote/${input.listId}`); + return { ok: true }; +} + +// -------- Add links to an existing list -------- +export async function addLinksToList(input: { + listId: string; + links: string; +}): Promise | Err> { + const auth = await requireUser(); + if (!auth.ok) return auth; + + const urls = parseLinks(input.links); + if (urls.length === 0) { + return { ok: false, error: "Paste at least one valid URL." }; + } + + const svc = serviceClient(); + + // Verify ownership + const { data: list } = await svc + .from("promo_list") + .select("id") + .eq("id", input.listId) + .eq("user_id", auth.userId) + .maybeSingle(); + if (!list) return { ok: false, error: "List not found." }; + + const linkRows = urls.map((url) => ({ + list_id: input.listId, + url, + enabled: true, + })); + // Use upsert to skip duplicates (unique on list_id, url) + const { error, count } = await svc + .from("promo_link") + .upsert(linkRows, { onConflict: "list_id,url", ignoreDuplicates: true, count: "exact" }); + if (error) return { ok: false, error: error.message }; + + // Best-effort title fetching + fetchTitlesForList(input.listId, urls).catch(() => {}); + + revalidatePath(`/promote/${input.listId}`); + return { ok: true, added: count ?? urls.length }; +} + +// -------- Remove a link -------- +export async function removeLink(linkId: string): Promise { + const auth = await requireUser(); + if (!auth.ok) return auth; + + const svc = serviceClient(); + // Verify ownership via join + const { data: link } = await svc + .from("promo_link") + .select("id, list_id, promo_list!inner(user_id)") + .eq("id", linkId) + .maybeSingle(); + if (!link) return { ok: false, error: "Link not found." }; + const listOwner = (link as any).promo_list?.user_id; + if (listOwner !== auth.userId) return { ok: false, error: "Not authorized." }; + + const { error } = await svc.from("promo_link").delete().eq("id", linkId); + if (error) return { ok: false, error: error.message }; + + revalidatePath(`/promote/${link.list_id}`); + return { ok: true }; +} + +// -------- Toggle link enabled -------- +export async function toggleLink(linkId: string, enabled: boolean): Promise { + const auth = await requireUser(); + if (!auth.ok) return auth; + + const svc = serviceClient(); + const { data: link } = await svc + .from("promo_link") + .select("id, list_id, promo_list!inner(user_id)") + .eq("id", linkId) + .maybeSingle(); + if (!link) return { ok: false, error: "Link not found." }; + const listOwner = (link as any).promo_list?.user_id; + if (listOwner !== auth.userId) return { ok: false, error: "Not authorized." }; + + const { error } = await svc + .from("promo_link") + .update({ enabled }) + .eq("id", linkId); + if (error) return { ok: false, error: error.message }; + + revalidatePath(`/promote/${link.list_id}`); + return { ok: true }; +} + +// -------- Pause / Resume / Delete list -------- +export async function pausePromoteList(listId: string): Promise { + const auth = await requireUser(); + if (!auth.ok) return auth; + const svc = serviceClient(); + const { error } = await svc + .from("promo_list") + .update({ status: "paused", pause_reason: "manual" }) + .eq("id", listId) + .eq("user_id", auth.userId); + if (error) return { ok: false, error: error.message }; + revalidatePath("/promote"); + return { ok: true }; +} + +export async function resumePromoteList(listId: string): Promise { + const auth = await requireUser(); + if (!auth.ok) return auth; + const svc = serviceClient(); + const { error } = await svc + .from("promo_list") + .update({ + status: "running", + pause_reason: null, + next_run_at: new Date().toISOString(), + }) + .eq("id", listId) + .eq("user_id", auth.userId); + if (error) return { ok: false, error: error.message }; + revalidatePath("/promote"); + return { ok: true }; +} + +export async function deletePromoteList(listId: string): Promise { + const auth = await requireUser(); + if (!auth.ok) return auth; + const svc = serviceClient(); + const { error } = await svc + .from("promo_list") + .delete() + .eq("id", listId) + .eq("user_id", auth.userId); + if (error) return { ok: false, error: error.message }; + revalidatePath("/promote"); + return { ok: true }; +} + +// -------- Post now (fire one tick immediately) -------- +export async function postNow(listId: string): Promise { + const auth = await requireUser(); + if (!auth.ok) return auth; + const svc = serviceClient(); + // Set next_run_at to now so the sweep picks it up immediately. + const { error } = await svc + .from("promo_list") + .update({ + next_run_at: new Date().toISOString(), + status: "running", + pause_reason: null, + }) + .eq("id", listId) + .eq("user_id", auth.userId); + if (error) return { ok: false, error: error.message }; + revalidatePath("/promote"); + revalidatePath(`/promote/${listId}`); + return { ok: true }; +} diff --git a/components/promote/add-links-form.tsx b/components/promote/add-links-form.tsx new file mode 100644 index 00000000..824ab2a5 --- /dev/null +++ b/components/promote/add-links-form.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { addLinksToList } from "@/app/actions/promote"; + +export function AddLinksForm({ listId }: { listId: string }) { + const router = useRouter(); + const [pending, start] = useTransition(); + const [links, setLinks] = useState(""); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setError(""); + setNotice(""); + start(async () => { + const result = await addLinksToList({ listId, links }); + if (!result.ok) { + setError(result.error); + return; + } + setLinks(""); + setNotice(`Added ${result.added} link${result.added !== 1 ? "s" : ""}.`); + router.refresh(); + }); + }; + + return ( +
+ {error && ( +

{error}

+ )} + {notice && ( +

{notice}

+ )} +