Skip to content

Commit debede1

Browse files
authored
feat: add Promote drip engine (Phase 1) (#93)
* feat: add Promote drip engine (Phase 1) Implements the Promote feature per docs/promote-prd.md: - Migration: promo_list, promo_link, promo_post tables + RLS + indexes - Topnav: Promote link added after Ads - Pages: /promote (index), /promote/new, /promote/[id], /promote/accounts - Server actions: full CRUD (create, update, pause, resume, delete, post now) - lib/promote/generatePitch.ts: dual-provider (Anthropic/OpenAI) copywriter with per-platform profiles and anti-repeat prompt injection - lib/promote/sweep.ts: processDuePromoteLists() with round-robin link selection, trickle/burst modes, credit gating, auto-pause on low balance - Worker: promoteSweep() on 60s interval, reuses sp_account pool + postViaAccount - Components: list-actions, promote-form, edit-form, add-links, link-list - Tests: parseLinks, cadenceLabel, CADENCE_PRESETS Closes #92 * fix: destructure count from sp_account query correctly
1 parent 3d26581 commit debede1

16 files changed

Lines changed: 2436 additions & 0 deletions

File tree

‎app/(app)/layout.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
3939
<Link href="/recent">Recent</Link>
4040
<Link href="/projects/new">New</Link>
4141
<Link href="/ads">Ads</Link>
42+
<Link href="/promote">Promote</Link>
4243
<Link href="/settings/integrations/github">GitHub</Link>
4344
<a href="/blog" target="_blank" rel="noreferrer">Blog ↗</a>
4445
{profile?.is_admin && <Link href="/admin">Admin</Link>}

‎app/(app)/promote/[id]/page.tsx‎

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import Link from "next/link";
2+
import { notFound } from "next/navigation";
3+
import { createClient } from "@/lib/supabase/server";
4+
import { cadenceLabel } from "@/lib/promote/generatePitch";
5+
import { PromoteListActions } from "@/components/promote/list-actions";
6+
import { PromoteEditForm } from "@/components/promote/promote-edit-form";
7+
import { AddLinksForm } from "@/components/promote/add-links-form";
8+
import { LinkList } from "@/components/promote/link-list";
9+
10+
export const metadata = { title: "Promote list" };
11+
12+
type Props = { params: Promise<{ id: string }> };
13+
14+
export default async function PromoteDetailPage({ params }: Props) {
15+
const { id } = await params;
16+
const supabase = await createClient();
17+
const {
18+
data: { user },
19+
} = await supabase.auth.getUser();
20+
if (!user) notFound();
21+
22+
const { data: list } = await supabase
23+
.from("promo_list")
24+
.select("*")
25+
.eq("id", id)
26+
.eq("user_id", user.id)
27+
.maybeSingle();
28+
if (!list) notFound();
29+
30+
const [{ data: links }, { data: posts }, { data: accounts }] = await Promise.all([
31+
supabase
32+
.from("promo_link")
33+
.select("id, url, title, angle, enabled, times_promoted, last_promoted_at, created_at")
34+
.eq("list_id", id)
35+
.order("created_at", { ascending: true }),
36+
supabase
37+
.from("promo_post")
38+
.select("id, link_id, platform, body, status, error, credits_spent, posted_at, provider, external_post_id")
39+
.eq("list_id", id)
40+
.order("created_at", { ascending: false })
41+
.limit(50),
42+
supabase
43+
.from("sp_account")
44+
.select("id, platform, handle")
45+
.eq("user_id", user.id)
46+
.eq("status", "active")
47+
.order("platform"),
48+
]);
49+
50+
const linkRows = (links ?? []) as Array<{
51+
id: string;
52+
url: string;
53+
title: string | null;
54+
angle: string | null;
55+
enabled: boolean;
56+
times_promoted: number;
57+
last_promoted_at: string | null;
58+
created_at: string;
59+
}>;
60+
61+
const postRows = (posts ?? []) as Array<{
62+
id: string;
63+
link_id: string;
64+
platform: string;
65+
body: string;
66+
status: string;
67+
error: string | null;
68+
credits_spent: number;
69+
posted_at: string | null;
70+
provider: string | null;
71+
external_post_id: string | null;
72+
}>;
73+
74+
const accountRows = (accounts ?? []) as Array<{
75+
id: string;
76+
platform: string;
77+
handle: string;
78+
}>;
79+
80+
const totalPosts = postRows.filter((p) => p.status === "posted").length;
81+
const totalCredits = postRows.reduce((sum, p) => sum + (p.credits_spent ?? 0), 0);
82+
83+
return (
84+
<div className="mx-auto max-w-4xl">
85+
<div className="flex items-center justify-between gap-4">
86+
<div>
87+
<Link href="/promote" className="text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]">
88+
&larr; All lists
89+
</Link>
90+
<h1 className="text-3xl font-bold">{list.name}</h1>
91+
</div>
92+
<PromoteListActions id={list.id} status={list.status} showEdit={false} />
93+
</div>
94+
95+
{/* Status bar */}
96+
<div className="mt-4 flex flex-wrap gap-3">
97+
<span
98+
className={`badge ${
99+
list.status === "running"
100+
? "badge-pass"
101+
: list.status === "paused"
102+
? "badge-warn"
103+
: ""
104+
}`}
105+
>
106+
{list.status}
107+
</span>
108+
{list.pause_reason === "insufficient_credits" && (
109+
<Link href="/settings/billing" className="badge badge-fail">
110+
Low credits — buy more
111+
</Link>
112+
)}
113+
<span className="text-sm text-[var(--color-muted)]">
114+
{cadenceLabel(list.cadence_seconds)} · {list.post_mode} · {linkRows.length} link{linkRows.length !== 1 ? "s" : ""} · {totalPosts} posts · {totalCredits} credits spent
115+
</span>
116+
</div>
117+
118+
{/* Edit form */}
119+
<section className="card mt-6 p-4">
120+
<h2 className="text-lg font-semibold">Settings</h2>
121+
<PromoteEditForm
122+
list={{
123+
id: list.id,
124+
name: list.name,
125+
cadence_seconds: list.cadence_seconds,
126+
post_mode: list.post_mode,
127+
brand_voice: list.brand_voice ?? "",
128+
target_account_ids: list.target_account_ids,
129+
}}
130+
accounts={accountRows}
131+
/>
132+
</section>
133+
134+
{/* Links */}
135+
<section className="mt-6">
136+
<div className="flex items-center justify-between">
137+
<h2 className="text-lg font-semibold">Links</h2>
138+
</div>
139+
<LinkList links={linkRows} />
140+
<div className="mt-4">
141+
<AddLinksForm listId={list.id} />
142+
</div>
143+
</section>
144+
145+
{/* Recent posts */}
146+
<section className="mt-6">
147+
<h2 className="text-lg font-semibold">Recent posts</h2>
148+
{postRows.length === 0 ? (
149+
<p className="mt-2 text-sm text-[var(--color-muted)]">No posts yet. The first drip will fire shortly if the list is running.</p>
150+
) : (
151+
<ul className="mt-2 space-y-2">
152+
{postRows.map((p) => (
153+
<li key={p.id} className="card p-3 text-sm">
154+
<div className="flex items-center justify-between gap-2">
155+
<div className="flex items-center gap-2">
156+
<span className="font-mono text-xs text-[var(--color-muted)]">{p.platform}</span>
157+
<span
158+
className={`badge text-xs ${
159+
p.status === "posted" ? "badge-pass" : p.status === "failed" ? "badge-fail" : ""
160+
}`}
161+
>
162+
{p.status}
163+
</span>
164+
{p.provider && (
165+
<span className="text-xs text-[var(--color-muted)]">via {p.provider}</span>
166+
)}
167+
</div>
168+
<span className="text-xs text-[var(--color-muted)]">
169+
{p.posted_at ? new Date(p.posted_at).toLocaleString() : "—"}
170+
</span>
171+
</div>
172+
<p className="mt-1 whitespace-pre-wrap break-words text-[var(--color-fg)]">
173+
{p.body.length > 300 ? p.body.slice(0, 300) + "..." : p.body}
174+
</p>
175+
{p.error && (
176+
<p className="mt-1 text-xs text-[var(--color-fail)]">{p.error}</p>
177+
)}
178+
</li>
179+
))}
180+
</ul>
181+
)}
182+
</section>
183+
</div>
184+
);
185+
}

0 commit comments

Comments
 (0)