Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
28 changes: 21 additions & 7 deletions components/subreddit-picker/CustomizePostDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ export const CustomizePostDialog: React.FC<CustomizePostDialogProps> = ({
const displayTitle = useCustomTitle ? customTitle : globalTitle;
const displayBody = useCustomBody ? customBody : globalBody;

const bodyError = bodyRequired && !displayBody.trim()
? 'Description is required for this community'
: null;

return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
Expand Down Expand Up @@ -255,18 +259,28 @@ export const CustomizePostDialog: React.FC<CustomizePostDialogProps> = ({
<span className="hidden sm:inline">AI</span>
</button>
</div>
<div className="flex justify-end mt-1">
<div className="flex justify-between items-center mt-1">
{bodyError ? (
<p className="text-xs text-red-500">{bodyError}</p>
) : (
<span />
)}
<span className={`text-xs ${displayBody.length > bodyMaxLength * 0.9 ? 'text-yellow-500' : 'text-muted-foreground'}`}>
{displayBody.length}/{bodyMaxLength}
</span>
</div>
</div>
) : (
<div className="mt-2 px-3 py-2 bg-muted/50 rounded-md text-sm text-muted-foreground">
{globalBody ? (
<>Using global: &quot;{globalBody.slice(0, 80)}{globalBody.length > 80 ? '...' : ''}&quot;</>
) : (
<span className="italic">No description set</span>
<div className="mt-2">
<div className="px-3 py-2 bg-muted/50 rounded-md text-sm text-muted-foreground">
{globalBody ? (
<>Using global: &quot;{globalBody.slice(0, 80)}{globalBody.length > 80 ? '...' : ''}&quot;</>
) : (
<span className="italic">No description set</span>
)}
</div>
{bodyError && (
<p className="text-xs text-red-500 mt-1">{bodyError}</p>
)}
</div>
)}
Expand All @@ -289,7 +303,7 @@ export const CustomizePostDialog: React.FC<CustomizePostDialogProps> = ({
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
Cancel
</Button>
<Button onClick={handleSave} className="cursor-pointer">
<Button onClick={handleSave} disabled={!!bodyError} className="cursor-pointer">
Save
</Button>
</DialogFooter>
Expand Down
4 changes: 2 additions & 2 deletions contexts/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
fetcher,
{
revalidateOnMount: true,
revalidateOnFocus: false,
revalidateOnFocus: true,
revalidateIfStale: false,
dedupingInterval: 300000, // 5 minutes - auth data rarely changes
dedupingInterval: 60_000, // 1 min — short enough to pick up post-payment webhook updates
errorRetryCount: 1,
shouldRetryOnError: (err) => {
// Don't retry on auth errors
Expand Down
24 changes: 12 additions & 12 deletions lib/preflightValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,18 +272,18 @@ function validateBody(input: PreflightInput): ValidationIssue[] {
if (!reqs) continue;

// Body required check
if (reqs.body_restriction_policy === 'required' && !body.trim()) {
issues.push({
code: 'BODY_REQUIRED',
severity: 'error',
subreddit,
message: `r/${subreddit} requires a description`,
suggestion: 'Add a description to your post',
field: 'body',
expectedCategory: 'fixable_now',
});
continue;
}
// if (reqs.body_restriction_policy === 'required' && !body.trim()) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Reinstate BODY_REQUIRED in preflight validation

Commenting out this branch removes the only global check that blocks submissions when a subreddit requires a description, so validatePreflight can now return canProceed=true for empty bodies and the queue/review flow will allow posting until Reddit rejects it downstream. The new inline check in CustomizePostDialog does not cover this because it only runs if that dialog is opened (and customization is gated by paid/trial in pages/index.tsx), so free users and any non-customized subreddit path lose required-body protection entirely.

Useful? React with 👍 / 👎.

// issues.push({
// code: 'BODY_REQUIRED',
// severity: 'error',
// subreddit,
// message: `r/${subreddit} requires a description`,
// suggestion: 'Add a description to your post',
// field: 'body',
// expectedCategory: 'fixable_now',
// });
// continue;
// }
Comment on lines +275 to +286
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

Restore server-side/preflight BODY_REQUIRED enforcement.

Line 275-Line 286 currently bypasses required-body validation by commenting out the check. This regresses preflight guarantees and allows invalid payloads to pass validatePreflight when subreddit policy requires a body.

🔧 Proposed fix
-    // Body required check
-    // if (reqs.body_restriction_policy === 'required' && !body.trim()) {
-    //   issues.push({
-    //     code: 'BODY_REQUIRED',
-    //     severity: 'error',
-    //     subreddit,
-    //     message: `r/${subreddit} requires a description`,
-    //     suggestion: 'Add a description to your post',
-    //     field: 'body',
-    //     expectedCategory: 'fixable_now',
-    //   });
-    //   continue;
-    // }
+    // Body required check
+    if (reqs.body_restriction_policy === 'required' && !body.trim()) {
+      issues.push({
+        code: 'BODY_REQUIRED',
+        severity: 'error',
+        subreddit,
+        message: `r/${subreddit} requires a description`,
+        suggestion: 'Add a description to your post',
+        field: 'body',
+        expectedCategory: 'fixable_now',
+      });
+      continue;
+    }

As per coding guidelines, "Never bypass validation" and "Always validate inputs."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@lib/preflightValidation.ts` around lines 275 - 286, Re-enable the server-side
required-body validation in the preflight routine by restoring the conditional
that checks reqs.body_restriction_policy === 'required' and empty body
(body.trim() === ''), and when triggered push the same issue object back onto
issues (code: 'BODY_REQUIRED', severity: 'error', subreddit, message:
`r/${subreddit} requires a description`, suggestion: 'Add a description to your
post', field: 'body', expectedCategory: 'fixable_now') and then continue; locate
this logic near the validatePreflight / preflight validation loop that currently
has the commented block referencing reqs.body_restriction_policy and issues and
restore it unchanged so required-body policy is enforced server-side.


// Min length (only if body exists)
if (body && reqs.body_text_min_length && body.length < reqs.body_text_min_length) {
Expand Down
38 changes: 38 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@sentry/nextjs": "^10.38.0",
"@supabase/supabase-js": "^2.90.1",
"@types/formidable": "^3.4.5",
"@vercel/analytics": "^1.6.1",
"autoprefixer": "^10.4.21",
"axios": "^1.7.7",
"better-sqlite3": "^11.7.0",
Expand Down
2 changes: 2 additions & 0 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Head from "next/head";
import dynamic from "next/dynamic";
import { useRouter } from "next/router";
import { SWRConfig } from "swr";
import { Analytics } from "@vercel/analytics/next";
import ErrorBoundary from "@/components/ErrorBoundary";
import { Toaster } from "@/components/ui/toaster";
import { ThemeProvider } from "@/contexts/ThemeContext";
Expand Down Expand Up @@ -181,6 +182,7 @@ export default function App({ Component, pageProps }: AppProps) {
</AuthProvider>
</SWRConfig>
</ErrorBoundary>
<Analytics />
</>
);
}
142 changes: 122 additions & 20 deletions pages/checkout/success.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,139 @@
import React, { useEffect } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import axios from 'axios';
import { mutate } from 'swr';
import { CheckCircle } from 'lucide-react';
import { CheckCircle, Loader2, ArrowRight } from 'lucide-react';
import { SWR_KEYS } from '@/lib/swr';

export default function CheckoutSuccess() {
// Refresh auth data on mount to pick up the new entitlement from webhook
const POLL_INTERVAL_MS = 2_000;
const MAX_POLL_DURATION_MS = 30_000;

/**
* Poll /api/me until entitlement === 'paid', or auto-resolve after timeout.
* Payment already succeeded on Dodo's side — the poll just waits for the
* webhook to propagate so we can pre-warm the SWR cache before navigation.
*/
const usePaymentConfirmation = () => {
const [ready, setReady] = useState(false);
const startRef = useRef(Date.now());

useEffect(() => {
mutate(SWR_KEYS.AUTH);
let cancelled = false;
let timer: ReturnType<typeof setTimeout>;

const poll = async () => {
if (cancelled) return;

if (Date.now() - startRef.current > MAX_POLL_DURATION_MS) {
if (!cancelled) setReady(true);
return;
Comment on lines +28 to +30
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

Do not mark payment as confirmed on timeout.

When polling exceeds MAX_POLL_DURATION_MS, the code sets ready to true and renders a confirmed Pro success state even if /api/me never returned entitlement === 'paid'. This can produce false success UI.

Proposed fix (separate confirmed vs timeout states)
-const usePaymentConfirmation = () => {
-  const [ready, setReady] = useState(false);
+const usePaymentConfirmation = () => {
+  const [status, setStatus] = useState<'checking' | 'confirmed' | 'timeout'>('checking');
   const startRef = useRef(Date.now());
@@
-      if (Date.now() - startRef.current > MAX_POLL_DURATION_MS) {
-        if (!cancelled) setReady(true);
+      if (Date.now() - startRef.current > MAX_POLL_DURATION_MS) {
+        if (!cancelled) setStatus('timeout');
         return;
       }
@@
         if (data?.entitlement === 'paid') {
           if (!cancelled) {
             mutate(SWR_KEYS.AUTH, data, { revalidate: false });
-            setReady(true);
+            setStatus('confirmed');
           }
           return;
         }
@@
-  return ready;
+  return status;
 };
@@
-  const ready = usePaymentConfirmation();
+  const status = usePaymentConfirmation();
+  const ready = status === 'confirmed';

Also applies to: 105-136

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pages/checkout/success.tsx` around lines 28 - 30, The timeout branch in the
polling logic currently sets setReady(true) which shows a confirmed Pro state
even when entitlement !== 'paid'; change it to not mark ready on timeout by
removing setReady(true) and instead set a distinct timeout state (e.g.,
setTimedOut(true) or setPollingExpired(true)) so the UI can distinguish "timed
out" vs "confirmed", update the render logic to only show confirmed Pro when
entitlement === 'paid' (or isConfirmed flag) and show a timeout/error view when
timedOut is true; apply the same change to the duplicate polling block around
the 105-136 area and reference startRef, MAX_POLL_DURATION_MS, cancelled,
setReady, and the polling/effect function when making edits.

}

try {
const { data } = await axios.get('/api/me');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Add timeout for /api/me poll requests

The new confirmation flow can block indefinitely when /api/me hangs, because the 30s max-duration check only runs before await axios.get('/api/me') and there is no request timeout; in that case the page stays on the spinner with no “Back to app” action exposed. This affects users on flaky/mobile networks where an XHR can stall without rejecting, so the poll loop never reaches the fallback path.

Useful? React with 👍 / 👎.

if (data?.entitlement === 'paid') {
if (!cancelled) {
mutate(SWR_KEYS.AUTH, data, { revalidate: false });
setReady(true);
}
return;
}
} catch {
// Transient error — keep polling
}

if (!cancelled) {
timer = setTimeout(poll, POLL_INTERVAL_MS);
}
};

poll();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, []);

return ready;
};

export default function CheckoutSuccess() {
const router = useRouter();
const ready = usePaymentConfirmation();
const [navigating, setNavigating] = useState(false);

const handleBackToApp = useCallback(async () => {
setNavigating(true);
try {
const { data } = await axios.get('/api/me');
await mutate(SWR_KEYS.AUTH, data, { revalidate: false });
} catch {
mutate(SWR_KEYS.AUTH);
}
router.push('/');
}, [router]);

return (
<>
<Head>
<title>Thank you - Reddit Multi Poster</title>
</Head>

<div className="min-h-viewport bg-[#0a0a0a] flex flex-col items-center justify-center p-4">
<div className="max-w-md w-full text-center space-y-6">
<div className="flex justify-center">
<CheckCircle className="h-16 w-16 text-green-500" aria-hidden="true" />
</div>
<h1 className="text-2xl font-semibold text-white">Thank you</h1>
<p className="text-zinc-400">
You&apos;re all set. You can now save unlimited communities and post to as many as you
want at once.
</p>
<Link
href="/"
className="inline-flex items-center justify-center rounded-lg bg-orange-500 px-6 py-3 text-sm font-medium text-white hover:bg-orange-600 transition-colors cursor-pointer"
>
Back to app
</Link>

{/* ── Confirming (polling for webhook) ── */}
{!ready && (
<>
<div className="flex justify-center">
<Loader2
className="h-16 w-16 text-violet-500 animate-spin"
aria-hidden="true"
/>
</div>
<h1 className="text-2xl font-semibold text-white">
Confirming your upgrade&hellip;
</h1>
<p className="text-zinc-400">
This usually takes just a few seconds.
</p>
</>
)}

{/* ── Ready — user is Pro ── */}
{ready && (
<>
<div className="flex justify-center">
<CheckCircle className="h-16 w-16 text-green-500" aria-hidden="true" />
</div>
<h1 className="text-2xl font-semibold text-white">
You&apos;re Pro now!
</h1>
<p className="text-zinc-400">
Unlimited communities, unlimited posts. No limits, ever.
</p>
<button
onClick={handleBackToApp}
disabled={navigating}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-orange-500 px-6 py-3 text-sm font-medium text-white hover:bg-orange-600 transition-colors cursor-pointer disabled:opacity-60 disabled:cursor-wait"
tabIndex={0}
aria-label="Go back to the app"
>
{navigating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
Loading&hellip;
</>
) : (
<>
Back to app
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</>
)}
</button>
</>
)}
</div>
</div>
</>
Expand Down
Loading
Loading