Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
/coverage
/playwright-report/
/test-results/
/demo-output/

# next.js
/.next/
Expand Down
31 changes: 31 additions & 0 deletions docs/DEMO_VIDEO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Product Hunt Demo Video (45–60s)

This repo includes a deterministic, **safe** demo-video capture flow that **never posts to Reddit**. It uses a demo-only streamed queue endpoint and a Playwright spec that records the UI flow.

## 1) Record the demo (Playwright)

```bash
npm run demo:video
```

Notes:
- This sets `NEXT_PUBLIC_QUEUE_DEMO_MODE=1` so the client posts to `POST /api/queue/demo` (simulated streaming).
- Video is forced **on** via `playwright.demo.config.ts`.
- The capture includes simple title cards via `/demo/cards?variant=...`.
- The raw video is written under `test-results/` (Playwright output).

## 2) Export to a shareable file

```bash
npm run demo:export
```

Output:
- `demo-output/reddit-multi-poster_product-hunt_demo_60s_1080p.mp4` (if `ffmpeg` is installed)
- otherwise `demo-output/reddit-multi-poster_product-hunt_demo_60s_1080p.webm`

## 3) Voiceover + captions (AI polish)

Suggested voiceover text: `docs/demo/product-hunt-demo-voiceover.md`

If you want to paste captions manually (or as a base for auto-captions): `docs/demo/product-hunt-demo.srt`
8 changes: 8 additions & 0 deletions docs/demo/product-hunt-demo-voiceover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# Voiceover (≈55s)

- Posting to multiple subreddits is tedious—and doing it too fast can get you flagged.
- Reddit Multi Poster lets you upload once, pick your communities, and post safely with smart scheduling.
- Paste your media, add a title, select your subreddits… and hit Start Posting.
- Watch the queue update in real time—done.
- Stop copy-pasting. Start creating.

28 changes: 28 additions & 0 deletions docs/demo/product-hunt-demo.srt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
1
00:00:00,000 --> 00:00:03,000
Post once. Reach 30+ communities.

2
00:00:03,000 --> 00:00:09,000
Copy/paste. Select flair. Repeat.\nRisk getting flagged if you post too quickly.

3
00:00:09,000 --> 00:00:18,000
Paste your media link.\nAdd a title.

4
00:00:18,000 --> 00:00:30,000
Pick 2–3 communities.\nHit “Start Posting”.

5
00:00:30,000 --> 00:00:43,000
Watch the queue update in real time.\nAll done.

6
00:00:43,000 --> 00:00:53,000
Stop copy-pasting.\nStart creating.

7
00:00:53,000 --> 00:01:00,000
Try it now: reddit-multi-poster.vercel.app

9 changes: 7 additions & 2 deletions hooks/usePostingQueue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ export const usePostingQueue = ({
hasFlairErrors,
onPostAttempt,
}: UsePostingQueueProps): UsePostingQueueReturn => {
const isQueueDemoModeEnabled =
process.env.NEXT_PUBLIC_QUEUE_DEMO_MODE === 'true' ||
process.env.NEXT_PUBLIC_QUEUE_DEMO_MODE === '1';

// Core state
const [logs, setLogs] = useState<LogEntry[]>([]);
const [running, setRunning] = useState(false);
Expand Down Expand Up @@ -179,6 +183,7 @@ export const usePostingQueue = ({
));

const hasFiles = batch.some(item => item.file || (item.files && item.files.length > 0));
const queueEndpoint = isQueueDemoModeEnabled && !hasFiles ? '/api/queue/demo' : '/api/queue';

let res: Response;
try {
Expand Down Expand Up @@ -207,13 +212,13 @@ export const usePostingQueue = ({
formData.append('sharedFileCount', sharedFiles.length.toString());
}

res = await fetch('/api/queue', {
res = await fetch(queueEndpoint, {
method: 'POST',
body: formData,
signal: controller.signal,
});
} else {
res = await fetch('/api/queue', {
res = await fetch(queueEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: batch, caption, prefixes }),
Expand Down
17 changes: 15 additions & 2 deletions hooks/useQueueJob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ const initialState: QueueJobState = {
endedAtMs: null,
};

const isQueueDemoModeEnabled =
process.env.NEXT_PUBLIC_QUEUE_DEMO_MODE === 'true' ||
process.env.NEXT_PUBLIC_QUEUE_DEMO_MODE === '1';

const demoHeaders = isQueueDemoModeEnabled ? { 'x-rmp-demo': '1' } : undefined;

const isTerminalStatus = (status: QueueJobStatus | null): boolean =>
status === 'completed' || status === 'failed' || status === 'cancelled';

Expand Down Expand Up @@ -245,6 +251,7 @@ export function useQueueJob(): UseQueueJobReturn {
try {
const response = await fetch(`/api/queue/process?jobId=${jobId}`, {
method: 'POST',
headers: demoHeaders,
signal: abortControllerRef.current.signal,
});

Expand Down Expand Up @@ -433,6 +440,7 @@ export function useQueueJob(): UseQueueJobReturn {
// Submit to API
const response = await fetch('/api/queue/submit', {
method: 'POST',
headers: demoHeaders,
body: formData,
});

Expand All @@ -459,7 +467,9 @@ export function useQueueJob(): UseQueueJobReturn {
}));

// Subscribe to updates and start processing
subscribeToJob(jobId);
if (!isQueueDemoModeEnabled) {
subscribeToJob(jobId);
}
startPolling(jobId);

return jobId;
Expand Down Expand Up @@ -539,6 +549,7 @@ export function useQueueJob(): UseQueueJobReturn {

const response = await fetch(`/api/queue/cancel/${jobId}`, {
method: 'POST',
headers: demoHeaders,
});

const data = await response.json();
Expand Down Expand Up @@ -575,7 +586,9 @@ export function useQueueJob(): UseQueueJobReturn {

const resumeJob = useCallback(async (jobId: string): Promise<void> => {
try {
const response = await fetch(`/api/queue/status/${jobId}`);
const response = await fetch(`/api/queue/status/${jobId}`, {
headers: demoHeaders,
});
const data = await response.json();

if (!response.ok || !data.job) {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"demo:video": "NEXT_PUBLIC_QUEUE_DEMO_MODE=1 npx playwright test -c playwright.demo.config.ts tests/demo/product-hunt-demo.spec.ts --project=chromium --workers=1 --headed",
"demo:export": "node scripts/export-demo-video.cjs",
"test:contracts": "playwright test tests/contracts --project=chromium",
"test:e2e:flow": "playwright test tests/flows --project=chromium --grep \"@flow-core|@flow-edge\"",
"test:e2e:regression": "playwright test --project=chromium --grep \"@flow-regression\"",
Expand Down
104 changes: 104 additions & 0 deletions pages/api/queue/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { NextApiRequest, NextApiResponse } from 'next';

type QueueItemInput = {
subreddit: string;
};

type DemoQueueRequestBody = {
items: QueueItemInput[];
caption?: string;
prefixes?: { f?: boolean; c?: boolean };
};

const sleep = async (ms: number): Promise<void> => {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
};

const writeLine = (res: NextApiResponse, payload: unknown): void => {
res.write(`${JSON.stringify(payload)}\n`);
};

const isRecordLike = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null;

const parseBody = (req: NextApiRequest): DemoQueueRequestBody | null => {
const body = req.body as unknown;
if (!isRecordLike(body)) return null;
if (!('items' in body) || !Array.isArray(body.items)) return null;

const items = body.items
.map((item): QueueItemInput | null => {
if (!isRecordLike(item)) return null;
const subreddit = item.subreddit;
if (typeof subreddit !== 'string' || subreddit.trim().length === 0) return null;
return { subreddit };
})
.filter((item): item is QueueItemInput => item !== null);

if (items.length === 0) return null;

return {
items,
caption: typeof body.caption === 'string' ? body.caption : undefined,
prefixes: isRecordLike(body.prefixes) ? (body.prefixes as DemoQueueRequestBody['prefixes']) : undefined,
};
};

/**
* Demo-only queue processor.
*
* Purpose:
* - Provide a safe, deterministic, streamed queue for demo/video capture.
* - NEVER posts to Reddit.
*
* Enable client-side usage via NEXT_PUBLIC_QUEUE_DEMO_MODE=1 (see hooks/usePostingQueue.ts).
*/
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).json({ error: 'Method not allowed' });
}
Comment on lines +58 to +62
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing demo-mode guard — endpoint is accessible even when demo mode is disabled.

Unlike pages/api/queue/submit.ts and pages/api/queue/process.ts, which check NEXT_PUBLIC_QUEUE_DEMO_MODE and the x-rmp-demo header before entering demo logic, this endpoint has no such guard. In production (with demo mode off), anyone can hit /api/queue/demo and get simulated responses.

Proposed fix
 export default async function handler(req: NextApiRequest, res: NextApiResponse) {
   if (req.method !== 'POST') {
     res.setHeader('Allow', 'POST');
     return res.status(405).json({ error: 'Method not allowed' });
   }
 
+  const demoEnv = process.env.NEXT_PUBLIC_QUEUE_DEMO_MODE;
+  const demoHeader = req.headers['x-rmp-demo'];
+  const demoHeaderValue = Array.isArray(demoHeader) ? demoHeader[0] : demoHeader;
+  const isDemoEnabled =
+    (demoEnv === 'true' || demoEnv === '1') && demoHeaderValue === '1';
+
+  if (!isDemoEnabled) {
+    return res.status(403).json({ error: 'Demo mode is not enabled' });
+  }
+
   const parsed = parseBody(req);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).json({ error: 'Method not allowed' });
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).json({ error: 'Method not allowed' });
}
const demoEnv = process.env.NEXT_PUBLIC_QUEUE_DEMO_MODE;
const demoHeader = req.headers['x-rmp-demo'];
const demoHeaderValue = Array.isArray(demoHeader) ? demoHeader[0] : demoHeader;
const isDemoEnabled =
(demoEnv === 'true' || demoEnv === '1') && demoHeaderValue === '1';
if (!isDemoEnabled) {
return res.status(403).json({ error: 'Demo mode is not enabled' });
}
const parsed = parseBody(req);
🤖 Prompt for AI Agents
In `@pages/api/queue/demo.ts` around lines 58 - 62, The handler in
pages/api/queue/demo.ts lacks the demo-mode guard, so add the same check used in
pages/api/queue/submit.ts and pages/api/queue/process.ts: verify
process.env.NEXT_PUBLIC_QUEUE_DEMO_MODE === 'true' and that the request includes
the x-rmp-demo header (or return 405/403 as those files do) before executing
demo logic in the default exported async function handler; if the guard fails,
short-circuit the request with the same response/status used by the other demo
endpoints to prevent demo responses when demo mode is disabled.


const parsed = parseBody(req);
if (!parsed) {
return res.status(400).json({ error: 'Invalid request body' });
}

res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();

const delaySeconds = 2;
writeLine(res, { status: 'started', total: parsed.items.length });
await sleep(700);

for (let index = 0; index < parsed.items.length; index++) {
const item = parsed.items[index];
const subreddit = item.subreddit;

writeLine(res, { index, status: 'posting', subreddit });
await sleep(900);

writeLine(res, {
index,
status: 'success',
subreddit,
url: `https://reddit.com/r/${subreddit}/comments/demo${index}`,
id: `demo${index}`,
});
await sleep(600);

if (index < parsed.items.length - 1) {
writeLine(res, { index, status: 'waiting', delaySeconds });
await sleep(delaySeconds * 1000);
}
}

writeLine(res, { status: 'completed' });
return res.end();
}

60 changes: 60 additions & 0 deletions pages/api/queue/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,12 +47,72 @@ export default async function handler(
}

try {
const demoHeader = req.headers['x-rmp-demo'];
const demoHeaderValue = Array.isArray(demoHeader) ? demoHeader[0] : demoHeader;
const isDemoRequest =
(process.env.NEXT_PUBLIC_QUEUE_DEMO_MODE === 'true' ||
process.env.NEXT_PUBLIC_QUEUE_DEMO_MODE === '1') &&
demoHeaderValue === '1' &&
jobId.startsWith('demo_');

// Get user ID
const userId = await getUserId(req, res);
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}

if (isDemoRequest) {
addApiBreadcrumb('Demo queue processing started', { jobId });

res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache, no-transform');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders?.();

const write = (update: JobProgressUpdate) => {
res.write(JSON.stringify(update) + '\n');
if ('flush' in res && typeof res.flush === 'function') {
res.flush();
}
};

const sleep = async (ms: number): Promise<void> => {
await new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
};

const demoSubreddits = ['pics', 'images', 'gifs'];
write({ type: 'status', jobId, status: 'processing', currentIndex: 0 });
await sleep(700);

for (let index = 0; index < demoSubreddits.length; index++) {
const subreddit = demoSubreddits[index];

write({ type: 'progress', jobId, currentIndex: index });
await sleep(900);

const result: QueueJobResult = {
index,
subreddit,
status: 'success',
url: `https://reddit.com/r/${subreddit}/comments/demo${index}`,
postedAt: new Date().toISOString(),
};
write({ type: 'result', jobId, result });
await sleep(700);

if (index < demoSubreddits.length - 1) {
write({ type: 'waiting', jobId, waitSeconds: 2 });
await sleep(2000);
}
}

write({ type: 'complete', jobId });
res.end();
return;
}

// Get the job
const job = await getQueueJob(jobId);
if (!job) {
Expand Down
Loading
Loading