Skip to content

feat: practical donate UX, profile popover readability, and self-host flow#22

Merged
rohan-patnaik merged 10 commits intomainfrom
codex/donation-no-premium-pool
Feb 6, 2026
Merged

feat: practical donate UX, profile popover readability, and self-host flow#22
rohan-patnaik merged 10 commits intomainfrom
codex/donation-no-premium-pool

Conversation

@rohan-patnaik
Copy link
Copy Markdown
Owner

@rohan-patnaik rohan-patnaik commented Feb 6, 2026

Summary

  • refine donate modal flow and clarity:
    • keep UPI QR visible as fallback when clicking Open UPI App
    • ensure only one payment action is active at a time (Show UPI QR, Open UPI App, Pay by card)
    • support env-first donation identity values (NEXT_PUBLIC_DONATE_UPI_ID, NEXT_PUBLIC_DONATE_PAYEE_NAME, legacy name key fallback)
    • update copy to be payment-method neutral and creator-focused
  • improve profile dropdown readability (Clerk user popover) in light/dark:
    • stronger contrast for name/email/action rows/icons
    • clearer hover/focus states and spacing hierarchy
  • migrate Next.js auth entrypoint from deprecated middleware.ts to proxy.ts
    • preserve bypass behavior in local dev
    • add deterministic bypass header signal consumed by download route
  • add owner-maintained checklist doc for remaining manual setup tasks
  • include worker compatibility fix for fpdf2 API/runtime behavior
  • include lint ignore for Convex generated files

Validation

  • cd apps/web && npm run lint
  • cd apps/web && npx tsc --noEmit
  • cd apps/web && npm test
  • cd apps/web && npm run build
  • cd apps/worker && python3.11 -m compileall zenpdf_worker

CodeRabbit

  • ran coderabbit review --plain --type uncommitted --base main
  • addressed findings:
    • removed hardcoded personal payment identifiers from tracked defaults/docs
    • clarified env migration/fallback behavior
    • kept QR visible as fallback after UPI deep-link click
    • made auth-bypass signaling explicit in proxy.ts + download route guard path
  • attempted a final rerun, but CodeRabbit returned a temporary rate-limit window

Scope

  • frontend/UI + docs + minor worker compatibility fix
  • no DB schema or API contract changes

Summary by CodeRabbit

  • New Features

    • Light/dark theme toggle and persisted preference; floating Donate button with QR/checkout modal
    • Self-hosting guide page, new “Choose your route” homepage flow, and grouped/guided tools UI
  • Changes

    • Removed PREMIUM tier and ads‑free flag — tiers now ANON and Signed‑in
    • Introduced global pooled limits and pool-level usage metrics; increased some per-file and daily job limits
    • Major UI redesign: typography, colors, layout, header and tooling/status refinements
  • Documentation

    • Architecture, product, and release docs updated to reflect limits, UX, and donation guidance

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 6, 2026

📝 Walkthrough

Walkthrough

Removes the PREMIUM tier and adsFree flag; replaces premium gating with global pooled limits and global usage; adds theme context/toggle and theme init script; introduces a DonateBookmark (UPI/QR) component and env vars; large UI/design and CSS overhaul.

Changes

Cohort / File(s) Summary
Env & dependencies
apps/web/.env.example, apps/web/package.json
Removed premium env vars; added NEXT_PUBLIC_DONATE_* vars and qrcode + @types/qrcode.
Auth, schema & plan types
apps/web/convex/lib/auth.ts, apps/web/convex/schema.ts, apps/web/convex/lib/limits.ts, apps/web/src/lib/limits.ts, apps/web/src/lib/convex.ts
Dropped PREMIUM tier and adsFree; narrowed PlanTier; added GlobalLimits; snapshots include globalLimits/globalUsage.
Capacity & usage flow
apps/web/convex/capacity.ts, apps/web/convex/users.ts
Capacity now resolves global limits/usage alongside per-user counters and returns them in usage snapshots.
Theme system & providers
apps/web/src/components/ThemeModeProvider.tsx, apps/web/src/components/ThemeToggle.tsx, apps/web/src/app/providers.tsx, apps/web/src/app/layout.tsx
Added ThemeModeProvider, useThemeMode hook, ThemeToggle, and theme init script; integrated provider into app layout.
Donation UI
apps/web/src/components/DonateBookmark.tsx, apps/web/.env.example
New DonateBookmark component: UPI URI, dynamic QR generation or external QR, optional card embed; driven by NEXT_PUBLIC_DONATE_* env vars.
Design system & CSS
apps/web/src/app/globals.css, apps/web/tailwind.config.ts
Extensive dark-theme and token overhaul, adjusted radii/shadows, restyled components, responsive tweaks and animation timing changes.
Landing, tools & usage UI
apps/web/src/app/page.tsx, apps/web/src/app/tools/page.tsx, apps/web/src/app/usage-capacity/page.tsx, apps/web/src/components/SiteHeader.tsx
Major UI/IA changes: new landing layout, grouped tools UI, usage page shows pool/global metrics, header redesigned (path-based nav, theme toggle); supporter/premium UI removed.
Pages & auth UI tweaks
apps/web/src/app/sign-in/.../page.tsx, apps/web/src/app/sign-up/.../page.tsx, apps/web/src/app/self-host/page.tsx
Adjusted paddings and Clerk appearance to CSS vars; added Self-hosting guide page.
Proxy & download auth
apps/web/src/app/api/download/route.ts, apps/web/src/proxy.ts
Added header-based auth bypass (x-zenpdf-auth-bypassed) for download route; middleware renamed/updated to set header when bypassing.
Worker PDF & deps
apps/worker/zenpdf_worker/tools.py, apps/worker/requirements.txt
Changed PDF output/font handling and Unicode font logic; downgraded fpdf2 2.8.5→2.8.4.
Docs & checklists
docs/*
Removed premium references, updated PRD/ARCHITECTURE/ROADMAP, added UI redesign and owner action checklist docs.
New components & types
apps/web/src/components/DonateBookmark.tsx, apps/web/src/components/ThemeModeProvider.tsx, apps/web/src/components/ThemeToggle.tsx, apps/web/tests/integration/import-meta-glob.d.ts
Added DonateBookmark, ThemeModeProvider, ThemeToggle; added ImportMeta.glob typing for tests.

Sequence Diagram(s)

sequenceDiagram
  participant Browser as Client (Browser)
  participant UI as Web UI
  participant Convex as Convex Backend
  participant DB as Counters DB

  Browser->>UI: open Usage page or submit job
  UI->>Convex: capacity:getUsageSnapshot()
  Convex->>DB: read per-user usage counters
  Convex->>DB: read global/pool counters
  Convex->>Convex: resolveGlobalLimits()
  Convex-->>UI: return UsageSnapshot (user usage + globalLimits + globalUsage)
  UI-->>Browser: render usage, pool metrics, and enforce submission limits
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I nudged the premium hat aside,

pooled counters hum where limits bide.
Dark or light, the theme will play,
A tiny Donate hop today —
QR ready, nibble a byte.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.32% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately reflects the main changes: introducing donation UX improvements, profile popover readability enhancements, and self-host flow implementation. It is concise, specific, and directly summarizes the primary objectives.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/donation-no-premium-pool

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@rohan-patnaik
Copy link
Copy Markdown
Owner Author

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 6, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

🤖 Fix all issues with AI agents
In `@apps/web/.env.example`:
- Around line 26-29: The environment variable NEXT_PUBLIC_DONATE_UPI_NOTE
contains a space and should be quoted to avoid dotenv parsers truncating the
value; update the .env.example entry for NEXT_PUBLIC_DONATE_UPI_NOTE to wrap the
value in quotes (e.g., "Support ZenPDF") so parsers treat the entire string as
the value.

In `@apps/web/src/app/globals.css`:
- Around line 441-443: The dark-mode rule for .paper-button--danger only sets
color; update :root[data-theme="dark"] .paper-button--danger to also override
background and border-color to the same values used in the light-mode
`.paper-button--danger` rule so the button uses the intended light-pink
background and red border in dark theme; locate the dark selector and add
background and border-color properties matching the light-mode declarations in
the existing CSS.

In `@apps/web/src/app/page.tsx`:
- Around line 157-165: Replace the hardcoded "27 available" string in the
status-pill span with a computed value derived from your tools source of truth:
use the length/sum of items in toolShelves (or a shared tools registry constant)
to calculate the available count and render that value (e.g., `${count}
available`) inside the <span className="status-pill">; update the render logic
where the JSX block with className "paper-card" and the span with className
"status-pill" is defined so the displayed number stays in sync with toolShelves
or the chosen registry.

In `@apps/web/src/app/tools/page.tsx`:
- Around line 1033-1046: The button currently strips the browser focus ring via
the focus-visible:outline-none utility on the tool-card button (the element
using selectTool and aria-pressed with activeTool), leaving keyboard users
without any visible focus; remove that utility or replace it with accessible
focus styles (for example add focus-visible:ring-2,
focus-visible:ring-forest-600 and focus-visible:ring-offset-2 or another
clearly-contrasting focus-visible style) on the same button className so
keyboard focus is visibly indicated while preserving the rest of the tool-card
styling.

In `@apps/web/src/components/DonateBookmark.tsx`:
- Around line 93-103: The component leaks internal env var names to users by
rendering raw NEXT_PUBLIC_DONATE_* strings when not set; update the
DonateBookmark.tsx rendering logic around upiId (and the QR branch that uses
NEXT_PUBLIC_DONATE_UPI_QR_URL) so that if upiId is falsy you either hide the
donate UI entirely (remove the QR/image and donate button) or show a
non-developer-facing fallback like "Donations not configured" / "Donations
unavailable", and ensure you do not render the literal env var names (e.g.,
replace occurrences of {upiId || "Set NEXT_PUBLIC_DONATE_UPI_ID"} with the
generic fallback and conditionally render the QR block based on existence of
upiId/qrUrl).
- Around line 83-91: DonateBookmark.tsx renders an external QR image via the
Image component using NEXT_PUBLIC_DONATE_UPI_QR_URL; add the corresponding
domain (or a remotePatterns entry) to next.config.ts under images.remotePatterns
so Next.js allows that external URL at runtime. Update next.config.ts to include
the QR image host (or pattern) in images.remotePatterns (or images.domains) so
the Image in DonateBookmark.tsx can load qrUrl without 400 errors.
- Around line 56-61: The modal rendered by the DonateBookmark component lacks
keyboard and backdrop dismissal; add an Escape-key handler and backdrop
click-to-close: in DonateBookmark, when open is true register a useEffect that
listens for keydown and calls the component's close handler (e.g., onClose or
setOpen(false)) when event.key === 'Escape'; also add an onClick on the backdrop
element (the div with role="dialog" / surrounding backdrop) that calls the same
close handler and ensure the inner dialog panel stops propagation (e.g.,
onClick={e => e.stopPropagation()}) so clicks inside the dialog don't close it.

In `@apps/web/src/components/ThemeModeProvider.tsx`:
- Around line 17-22: getInitialTheme currently only reads
document.documentElement.dataset.theme so persisted preferences saved by
applyTheme are ignored; update getInitialTheme to first check localStorage for
the same key applyTheme writes (use the exact storage key used by applyTheme),
return that value ("dark" or "light") if present, and fall back to checking
document.documentElement.dataset.theme and then "light" if neither is set;
ensure you reference getInitialTheme and applyTheme so the storage key stays
consistent.
🧹 Nitpick comments (14)
apps/web/src/app/page.tsx (2)

73-92: Two of the three route cards link to the same page.

Both "Check limits first" (line 83) and "Need unlimited usage" (line 89) resolve to /usage-capacity. Users clicking two visually distinct CTAs and landing on the identical page may feel disoriented. Consider either linking the self-host card to a distinct anchor (e.g. /usage-capacity#self-host) or to the repository/README, so each card has a unique destination.

Suggested diff
   {
     title: "Need unlimited usage",
     description: "Run ZenPDF locally when you need full control and no cloud caps.",
-    href: "/usage-capacity",
+    href: "/usage-capacity#self-host",
     cta: "See self-host path",
   },

244-254: Step badge format is fine for the current data but fragile.

0{index + 1} produces "01", "02", "03" — correct for ≤ 9 steps. If the array ever grows past 9 items, badges would read "010". A padStart call would be more resilient, though this is purely a nitpick given the fixed 3-step array.

apps/web/src/app/tools/page.tsx (3)

943-953: String-prefix status classification is fragile.

statusClassName determines error/success styling by matching prefixes of the human-readable status string. If any message wording changes (e.g., "Unable""Failed"), the alert silently loses its error styling. This also forces every future setStatus call site to be aware of these prefix conventions.

Consider introducing a separate piece of state for the status kind:

♻️ Suggested approach
- const [status, setStatus] = useState<string | null>(null);
+ const [status, setStatus] = useState<{ message: string; kind: "error" | "success" | "info" } | null>(null);

Then set it explicitly at each call site, e.g.:

setStatus({ message: "Upload exactly two PDFs to compare.", kind: "error" });
setStatus({ message: "Job queued. Results will appear below.", kind: "success" });

And derive the class directly:

const statusClassName = status === null
  ? ""
  : status.kind === "error"
    ? "alert alert--error"
    : status.kind === "success"
      ? "alert alert--success"
      : "alert";

970-981: Hardcoded angle constraints leak domain knowledge into a generic renderer.

Lines 974–976 special-case field.key === "angle" to set min/max/step. This couples renderField to a specific tool's field. You can keep the renderer generic by extending ToolField:

♻️ Suggested approach
 type ToolField = {
   key: string;
   label: string;
   placeholder: string;
   helper?: string;
   type?: "text" | "number" | "password" | "textarea";
   required?: boolean;
+  min?: number;
+  max?: number;
+  step?: number;
 };

Then in the "Rotate PDF" field definition:

{ key: "angle", label: "Angle", placeholder: "90", helper: "Use 90, 180, or 270.", type: "number", min: 90, max: 270, step: 90 },

And in renderField:

-  min={field.key === "angle" ? 90 : undefined}
-  max={field.key === "angle" ? 270 : undefined}
-  step={field.key === "angle" ? 90 : undefined}
+  min={field.min}
+  max={field.max}
+  step={field.step}

685-1267: Consider extracting sub-modules to reduce file size.

At ~1267 lines this file carries tool definitions, constants, helper utilities, a sub-component (JobCard), and the full page component. Extracting TOOLS/TOOL_GROUPS into a tools-data.ts module and JobCard into its own file would improve navigability without changing behavior — but this can be deferred.

apps/web/convex/lib/limits.ts (2)

3-3: Duplicate type definitions across server and client.

PlanTier and GlobalLimits are defined identically here and in apps/web/src/lib/limits.ts. This is a common pattern in Convex projects (separate server/client boundaries), but if possible, consider a shared types module to avoid future drift.

#!/bin/bash
# Verify the duplicate type definitions
echo "=== convex/lib/limits.ts PlanTier ==="
rg -n "export type PlanTier" --type=ts

echo ""
echo "=== GlobalLimits definitions ==="
rg -n "export type GlobalLimits" --type=ts

Also applies to: 13-20


39-46: Global daily-minutes cap of 120 is fairly tight.

With PREMIUM removed, all users share this 120-minute global daily pool. If heavy tools (OCR, PDF/A) consume significant per-job minutes, this cap could be reached quickly during modest concurrent usage. Ensure monitoring/alerting is in place to detect when this limit is hit and consider whether the cap should be tunable at runtime (it already is via env override, which is good).

apps/web/src/app/usage-capacity/page.tsx (2)

308-313: "Pool concurrency cap" bar always renders at 100% — consider a different presentation.

Both value and limit are globalLimits.maxConcurrentJobs, so the bar will always be completely full. Unlike the other bars that show usage against a ceiling, this one just displays a static configuration value. The filled bar could mislead users into thinking concurrency is maxed out.

A simple info row or the badge prop might convey this more clearly — e.g. badge={String(globalLimits.maxConcurrentJobs)} with a limit of some reasonable upper bound, or just render it as static text outside of UsageBar.


170-183: Default fallback values of 0 produce confusing pool metrics before the snapshot loads.

When snapshot is undefined, globalLimits.maxJobsPerDay and maxDailyMinutes are 0, so the pool section renders "0 of 0 used · 0 remaining". The "Loading latest snapshot…" pill on Line 230 partially mitigates this, but the pool section still shows zero-denominator metrics. Consider either hiding the pool section while !snapshot, or using more meaningful sentinel defaults.

apps/web/src/app/sign-up/[[...sign-up]]/page.tsx (1)

9-17: Redundant appearance override — already set by ClerkProvider.

providers.tsx already configures colorPrimary, colorBackground, and colorText with identical values on the wrapping ClerkProvider. The per-component appearance here is fully redundant and can be removed to keep a single source of truth.

Proposed simplification
-        <SignUp
-          appearance={{
-            variables: {
-              colorPrimary: "rgb(var(--forest-600))",
-              colorBackground: "rgb(var(--paper-50))",
-              colorText: "rgb(var(--ink-900))",
-            },
-          }}
-        />
+        <SignUp />
apps/web/src/components/DonateBookmark.tsx (1)

30-43: Timeout not cleared on unmount.

If the component unmounts within the 2-second window after a copy, setTimeout will fire setCopyState on an unmounted component. While React 18+ tolerates this, it's a minor leak. A useRef-based cleanup or useEffect cleanup would be cleaner, but this is low priority.

apps/web/src/app/layout.tsx (1)

57-61: DonateBookmark is always rendered, even when donation is not configured.

When NEXT_PUBLIC_DONATE_UPI_ID is empty, the fixed "Support" button still appears and opens a modal showing raw env-var names. Consider conditionally rendering <DonateBookmark /> only when the UPI ID is set, or guarding inside the component itself (the root cause is in DonateBookmark.tsx, flagged there).

apps/web/convex/capacity.ts (1)

10-29: Consider parallelizing the three independent calls in getCapacitySnapshot.

resolveBudgetState, resolveGlobalLimits, and resolveGlobalUsageCounter are independent of each other, yet they're awaited sequentially. This is already done correctly with Promise.all in getUsageSnapshot below.

♻️ Proposed parallel fetch
 export const getCapacitySnapshot = query({
   args: {},
   handler: async (ctx) => {
     const now = Date.now();
-    const budget = await resolveBudgetState(ctx, now);
-    const limits = await resolveGlobalLimits(ctx);
-    const { counter, periodStart } = await resolveGlobalUsageCounter(ctx, now);
+    const [budget, limits, { counter, periodStart }] = await Promise.all([
+      resolveBudgetState(ctx, now),
+      resolveGlobalLimits(ctx),
+      resolveGlobalUsageCounter(ctx, now),
+    ]);
 
     return {
       budget,
       limits,
       usage: counter ?? {
         periodStart,
         jobsUsed: 0,
         minutesUsed: 0,
         bytesProcessed: 0,
       },
     };
   },
 });
apps/web/src/lib/convex.ts (1)

4-4: GlobalLimits, PlanLimits, and PlanTier are duplicated across client and server boundaries.

These types are defined identically in both apps/web/src/lib/limits.ts and apps/web/convex/lib/limits.ts. If either copy drifts, the client type will silently mismatch the actual Convex return shape at runtime.

Consider re-exporting from a single shared source (e.g., convex/lib/limits.ts → re-exported via a shared barrel), or adding a compile-time assertion that both shapes are assignable to each other.

Comment on lines +1033 to +1046
<button
key={item.id}
type="button"
onClick={() => selectTool(item.id)}
aria-pressed={item.id === activeTool}
className={`tool-card w-full rounded-xl border p-3 text-left transition focus-visible:outline-none ${
item.id === activeTool
? "border-forest-600 bg-sage-200/45"
: "border-paper-200 bg-paper-50 hover:border-forest-500/50"
}`}
>
<h3 className="text-sm font-semibold text-ink-900">{item.label}</h3>
<p className="mt-1 text-xs text-ink-500">{item.description}</p>
</button>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if the file exists and verify the code snippet
head -1050 apps/web/src/app/tools/page.tsx | tail -30

Repository: rohan-patnaik/ZenPDF

Length of output: 1620


🏁 Script executed:

#!/bin/bash
# Search for the tool-card CSS class definition
fd . --type f \( -name "*.css" -o -name "*.scss" -o -name "*.module.css" -o -name "*.module.scss" \) | xargs rg "tool-card" -n

Repository: rohan-patnaik/ZenPDF

Length of output: 572


🏁 Script executed:

#!/bin/bash
# Also check for tool-card in any TSX/TS files that might contain CSS-in-JS
rg "tool-card" apps/web/src --type ts --type tsx -n

Repository: rohan-patnaik/ZenPDF

Length of output: 91


🏁 Script executed:

#!/bin/bash
# Read the tool-card CSS class definition from globals.css
sed -n '236,260p' apps/web/src/app/globals.css

Repository: rohan-patnaik/ZenPDF

Length of output: 529


focus-visible:outline-none removes the keyboard focus indicator without a visible replacement.

Line 1038 suppresses the browser's default focus ring. The tool-card CSS class and inline Tailwind classes provide no alternative focus style, leaving keyboard users without a visible indicator of which button is focused.

🤖 Prompt for AI Agents
In `@apps/web/src/app/tools/page.tsx` around lines 1033 - 1046, The button
currently strips the browser focus ring via the focus-visible:outline-none
utility on the tool-card button (the element using selectTool and aria-pressed
with activeTool), leaving keyboard users without any visible focus; remove that
utility or replace it with accessible focus styles (for example add
focus-visible:ring-2, focus-visible:ring-forest-600 and
focus-visible:ring-offset-2 or another clearly-contrasting focus-visible style)
on the same button className so keyboard focus is visibly indicated while
preserving the rest of the tool-card styling.

Comment on lines +83 to +91
{qrUrl ? (
<div className="surface-muted mt-4 p-3">
<Image
src={qrUrl}
alt="UPI QR code"
width={224}
height={224}
className="mx-auto h-56 w-56 rounded-md object-contain"
/>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find and display next.config file
find . -name "next.config.*" -type f -exec echo "File: {}" \; -exec cat {} \;

Repository: rohan-patnaik/ZenPDF

Length of output: 374


🏁 Script executed:

#!/bin/bash
# Search for NEXT_PUBLIC_DONATE_UPI_QR_URL in codebase
rg 'NEXT_PUBLIC_DONATE_UPI_QR_URL' -A 2 -B 2

# Also check for .env files
echo "=== .env files ==="
find . -name ".env*" -type f | head -20

Repository: rohan-patnaik/ZenPDF

Length of output: 1334


🏁 Script executed:

#!/bin/bash
# Read the DonateBookmark.tsx file
cat -n apps/web/src/components/DonateBookmark.tsx

Repository: rohan-patnaik/ZenPDF

Length of output: 4931


Add images.remotePatterns configuration to next.config.ts for the QR image domain.

The Image component in DonateBookmark.tsx (line 86) uses src={qrUrl} from NEXT_PUBLIC_DONATE_UPI_QR_URL, which can be an external URL. Next.js requires all external image domains to be listed in images.remotePatterns (or the legacy images.domains) in next.config.ts. Without this configuration, the image will fail at runtime with a 400 error when the environment variable is set to an external URL.

🤖 Prompt for AI Agents
In `@apps/web/src/components/DonateBookmark.tsx` around lines 83 - 91,
DonateBookmark.tsx renders an external QR image via the Image component using
NEXT_PUBLIC_DONATE_UPI_QR_URL; add the corresponding domain (or a remotePatterns
entry) to next.config.ts under images.remotePatterns so Next.js allows that
external URL at runtime. Update next.config.ts to include the QR image host (or
pattern) in images.remotePatterns (or images.domains) so the Image in
DonateBookmark.tsx can load qrUrl without 400 errors.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/src/app/globals.css`:
- Around line 502-512: The responsive rule makes .paper-button--ghost width:100%
on small screens and unintentionally stretches the icon .theme-toggle (which
relies on width:2rem); inside the existing `@media` (max-width: 640px) block
override the width for the theme toggle by adding a more specific selector such
as .theme-toggle.paper-button--ghost (or .theme-toggle) and reset its width back
to 2rem (or width:auto) so the icon button keeps its intended fixed size while
other .paper-button variants remain full-width.
🧹 Nitpick comments (1)
apps/web/src/app/globals.css (1)

114-128: Button variants as standalone classes — intentional but worth a brief note.

.paper-button--ghost and .paper-button--danger are treated as independent classes rather than BEM modifiers applied alongside .paper-button. This works because Lines 114-128 share the base styles across all three selectors. Just be aware that any future base-only change (e.g., adding a new shared property to .paper-button alone) will silently skip the other two variants.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/src/app/globals.css`:
- Around line 165-173: The .paper-button--danger rule uses hardcoded rgb(...)
values which prevent dark-mode token swapping; update the background
declarations to use the CSS color tokens instead (use rgb(var(--rose-100)) for
the default background and rgb(var(--rose-200)) for the :hover background) in
the .paper-button--danger and .paper-button--danger:hover selectors so dark-mode
token values are applied automatically and you can remove/avoid explicit
dark-mode overrides.
🧹 Nitpick comments (3)
apps/web/src/components/DonateBookmark.tsx (2)

43-52: Fallback QR generation via quickchart.io is an uncontrolled external dependency.

When NEXT_PUBLIC_DONATE_UPI_QR_URL is not set, the component silently calls quickchart.io to generate a QR code at runtime. This introduces an implicit third-party dependency that:

  • May have rate limits or availability issues, causing a broken image in production.
  • Sends the full UPI URI (containing the payee ID/name) to an external service.

Consider documenting this fallback behaviour (e.g., in the .env.example or a comment) so deployers are aware, or generate the QR client-side using a library like qrcode.react.


13-14: Hardcoded personal UPI credentials as defaults.

The fallback values contain a real personal UPI ID and name. Anyone who forks the repo and forgets to set the env vars will unknowingly direct donations to the original author. This may be intentional, but it's unusual for open-source defaults — typically the fallback would be empty or clearly placeholder (e.g., yourname@upi), with the real values only in .env.

apps/web/src/app/globals.css (1)

472-484: Dark-mode primary button flips to an outlined/ghost style — verify this is intentional.

In light mode, .paper-button is a filled green button (background: rgb(var(--forest-600)), white text). In dark mode it becomes a dark surface (background: rgb(var(--paper-100))) with green text — essentially ghost-like. This means primary and ghost buttons look very similar in dark mode (both are dark surfaces with green accents, differing mainly in border treatment).

If the intent is to maintain a distinct filled CTA in dark mode, consider keeping a green (or slightly muted green) background instead.

@rohan-patnaik rohan-patnaik changed the title feat: polish ZenPDF UI theme and capacity docs feat: improve donate modal UX/accessibility and add self-host guide Feb 6, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@apps/web/src/app/globals.css`:
- Around line 43-45: Dark-mode hover for .donate-fab is invisible because
--forest-700, --forest-600, and --forest-500 all resolve to the same value;
update the dark-mode token values so the hover color differs or change the hover
rule to use a darker token/adjust box-shadow. Specifically, modify the dark-mode
CSS variable definitions for --forest-700 (or --forest-600) to a slightly darker
RGB than 63 207 142, or alter the .donate-fab dark override hover rule to
reference a distinct variable (e.g., --forest-700-dark) or increase box-shadow
contrast so .donate-fab:hover produces a visible background change.

In `@apps/web/src/app/page.tsx`:
- Around line 87-92: The object with title "Need unlimited usage" currently
links to href "/usage-capacity" but the CTA and title indicate it should point
to the self-host guide; update that object's href value to "/self-host" (locate
the object literal in apps/web/src/app/page.tsx with title "Need unlimited
usage") so the CTA "See self-host path" and title navigate to the dedicated
self-hosting page.

In `@apps/web/src/components/DonateBookmark.tsx`:
- Around line 20-21: The component currently hardcodes personal payment details
in defaultUpiName and defaultUpiId; remove those literal defaults and instead
read values from environment/config (e.g., process.env or a passed prop) and
change the rendering logic in DonateBookmark (or its containing component) so
the Donate button is only shown when both UPI name and UPI id are explicitly
provided; if the env/config vars are missing, do not render the button (or
render a neutral disabled/placeholder message) so forks won't silently route
donations to the original author.
- Around line 362-368: The iframe in DonateBookmark.tsx currently sets
allow="payment *; clipboard-write", which grants the Payment Request API to any
origin; update the iframe usage (the element rendering resolvedCardEmbedUrl) to
scope permissions to the specific embed origin (e.g., allow="payment
https://embed.example.com; clipboard-write") or remove the permissive allow
attribute entirely so permissions default to the iframe origin; ensure the
change targets the iframe rendering resolvedCardEmbedUrl and adjust the origin
string to the actual payment provider domain.
🧹 Nitpick comments (5)
apps/web/src/app/page.tsx (1)

245-249: Step numbering format will break for 10+ items.

0{index + 1} produces "01", "02", etc. but would give "010" for a tenth step. With only 3 steps this is fine, but a padStart approach would be more robust:

{String(index + 1).padStart(2, "0")}

Low priority given the fixed step count.

apps/web/src/app/self-host/page.tsx (1)

49-52: URLs rendered as shell commands look misleading.

Lines 50–51 contain URLs (http://localhost:3000, https://dashboard.convex.dev) but they're in the commands array, which renders inside <pre><code>. This makes them look like terminal commands rather than informational links.

Consider either making them clickable <a> elements or adding a separate field (e.g., links) to the SetupStep type for non-command references.

apps/web/src/app/usage-capacity/page.tsx (1)

308-313: "Pool concurrency cap" bar is always at 100% — it shows a static limit, not usage.

Both value and limit are globalLimits.maxConcurrentJobs, so the bar is perpetually full. Unlike the other UsageBars that display consumed vs allowed, this one just displays a configuration value.

Consider either showing actual concurrent jobs in-flight (if that data is available in the snapshot) or rendering this as a simple label/badge instead of a progress bar:

-                <UsageBar
-                  label="Pool concurrency cap"
-                  value={globalLimits.maxConcurrentJobs}
-                  limit={globalLimits.maxConcurrentJobs}
-                  helper={`${globalLimits.maxConcurrentJobs} jobs can run concurrently`}
-                />
+                <div className="paper-card p-5">
+                  <div className="flex items-start justify-between gap-4">
+                    <div>
+                      <p className="text-sm font-semibold text-ink-900">Pool concurrency cap</p>
+                      <p className="mt-1 text-xs text-ink-500">{globalLimits.maxConcurrentJobs} jobs can run concurrently</p>
+                    </div>
+                    <span className="status-pill">{globalLimits.maxConcurrentJobs}</span>
+                  </div>
+                </div>
apps/web/src/app/globals.css (2)

304-308: Consider extracting warning/amber color tokens.

The .status-pill--warning light-mode rule (Line 305, 307) and dark override (Line 664-665) use hardcoded amber/yellow RGB values (rgb(202 138 4 / 0.35), rgb(133 77 14), rgb(251 191 36 / 0.45), rgb(253 230 138)) rather than CSS custom properties. Unlike the green and rose palettes which have tokens, there are no --amber-* or --warn-* tokens. Adding them would improve consistency and make future dark-mode adjustments a single-point change, matching the approach used for --rose-100, --gold-200, and --forest-*.

Also applies to: 663-666


507-511: Dark-mode body gradient uses a different circle at origin than light mode.

Light mode (Line 70) uses circle at top right, while dark mode (Line 509) uses circle at top center. This is likely intentional for aesthetic reasons, but worth confirming the asymmetry is deliberate — it will cause a noticeable layout shift during theme transitions if animated.

Comment on lines +20 to +21
const defaultUpiName = "Rohan Patnaik";
const defaultUpiId = "rohanpatnaik1997-1@okhdfcbank";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Hardcoded personal payment details as defaults.

defaultUpiName and defaultUpiId contain the developer's real name and bank-linked UPI ID. Anyone deploying this open-source project without setting env vars will silently route donations to the original author. This is presumably intentional for the hosted instance, but for forks it's a subtle gotcha.

Consider guarding: if neither env var is set, hide the donate button entirely rather than silently defaulting to a specific person's account.

🤖 Prompt for AI Agents
In `@apps/web/src/components/DonateBookmark.tsx` around lines 20 - 21, The
component currently hardcodes personal payment details in defaultUpiName and
defaultUpiId; remove those literal defaults and instead read values from
environment/config (e.g., process.env or a passed prop) and change the rendering
logic in DonateBookmark (or its containing component) so the Donate button is
only shown when both UPI name and UPI id are explicitly provided; if the
env/config vars are missing, do not render the button (or render a neutral
disabled/placeholder message) so forks won't silently route donations to the
original author.

@rohan-patnaik rohan-patnaik changed the title feat: improve donate modal UX/accessibility and add self-host guide feat: practical donate UX, profile popover readability, and self-host flow Feb 6, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@apps/web/src/app/api/download/route.ts`:
- Around line 41-44: The route currently sets disableAuth using the raw request
header which is spoofable; update the disableAuth computation in route.ts so the
header-based bypass is only honored when the env guard is true (i.e.
process.env.ZENPDF_DISABLE_AUTH === "1" && process.env.NODE_ENV !==
"production") before reading request.headers.get("x-zenpdf-auth-bypassed"), or
else rely solely on shouldBypassAuth() from the middleware; specifically modify
the disableAuth assignment (and any logic that sets allowAnonAccess) to require
the same environment check before trusting the "x-zenpdf-auth-bypassed" header.

In `@apps/web/src/proxy.ts`:
- Around line 22-30: The request header x-zenpdf-auth-bypassed is spoofable by
clients; in proxy() when shouldBypassAuth() is false (Clerk branch) remove/strip
that header from incoming requests before calling NextResponse.next to prevent
spoofing, and in the downstream download route handler (the function that reads
the header in route.ts) change the logic to only honor x-zenpdf-auth-bypassed
when shouldBypassAuth() returns true (i.e., gate acceptance of that header
behind the same environment check), ensuring both defense-in-depth in proxy()
and the authoritative check in the download route handler.
🧹 Nitpick comments (2)
docs/OWNER_ACTION_ITEMS.md (1)

42-42: Minor grammar nit: use a hyphen in "custom-hosted".

Static analysis flagged this compound adjective.

Proposed fix
-- [ ] (Optional) Use a custom hosted QR image instead of generated QR.
+- [ ] (Optional) Use a custom-hosted QR image instead of generated QR.
apps/web/src/components/DonateBookmark.tsx (1)

8-19: Consider reducing state count with a reducer or a state machine.

The component manages 7 useState hooks plus 3 refs. Several of the states are tightly coupled (e.g., showQr, showCardCheckout, activePaymentAction are always set together in handleOpen, handleClose, and the button handlers). A single useReducer or a discriminated union state would reduce the risk of inconsistent state combinations and simplify the handlers.

This is optional and can be deferred.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@apps/web/src/proxy.ts`:
- Around line 22-24: Create a new middleware entry file that re-exports the
existing middleware function and its matcher so Next.js will load it: add a
`middleware.ts` in the project's src root that imports `proxy` and `config` from
the existing `proxy.ts` and re-exports `proxy` as the default export and
`config` as a named export (i.e., export `{ proxy as default, config } from
"./proxy"`), ensuring the middleware entrypoint is recognized and Clerk
auth/header sanitization runs.
🧹 Nitpick comments (4)
apps/web/src/app/page.tsx (1)

245-255: Step number format assumes ≤ 9 steps.

0{index + 1} renders "01", "02", "03" — fine for three steps. If steps ever grows past 9 items, this would produce "010", "011", etc. A trivial guard with String(index + 1).padStart(2, "0") would be more robust, but with only three steps this is purely cosmetic.

Optional: future-proof step numbering
-                  <div className="flex h-8 w-8 items-center justify-center rounded-lg border border-paper-300 bg-paper-100 text-xs font-semibold text-forest-700">
-                    0{index + 1}
+                  <div className="flex h-8 w-8 items-center justify-center rounded-lg border border-paper-300 bg-paper-100 text-xs font-semibold text-forest-700">
+                    {String(index + 1).padStart(2, "0")}
apps/web/src/app/api/download/route.ts (1)

88-89: Consider RFC 5987 encoding for non-ASCII filenames.

sanitizeFilename strips dangerous characters, but if a user uploads a file with non-ASCII characters (e.g., Unicode), the bare filename="…" in Content-Disposition may not render correctly in all browsers. Adding filename*=UTF-8''… as a fallback would improve compatibility.

Proposed fix
- headers.set("Content-Disposition", `attachment; filename="${sanitizeFilename(filename)}"`);
+ const safe = sanitizeFilename(filename);
+ headers.set(
+   "Content-Disposition",
+   `attachment; filename="${safe}"; filename*=UTF-8''${encodeURIComponent(safe)}`,
+ );
apps/web/src/components/DonateBookmark.tsx (1)

8-11: Hide the donate FAB entirely when no donation method is configured.

When both upiId and cardEmbedUrl are empty (i.e., the deployer hasn't configured any donation method), the component still renders a floating button that opens a non-functional modal. An early return would avoid confusing end users.

Proposed fix
 export default function DonateBookmark() {
   const [open, setOpen] = useState(false);
   const [showQr, setShowQr] = useState(true);
   const [showCardCheckout, setShowCardCheckout] = useState(false);
+
+  // --- env reads (hoisted before early return so hooks above are always called) ---
+  const upiId = (process.env.NEXT_PUBLIC_DONATE_UPI_ID ?? "").trim();
+  const cardEmbedUrl = (process.env.NEXT_PUBLIC_DONATE_CARD_EMBED_URL ?? "").trim();
+
+  if (!upiId && !cardEmbedUrl) {
+    return null;
+  }

Note: hooks must remain above the early return to satisfy the Rules of Hooks.

apps/web/src/app/tools/page.tsx (1)

949-959: Status styling relies on fragile string-prefix matching.

statusClassName checks whether status starts with specific substrings like "Unable", "Enter", "Upload exactly", etc. Any future copy change silently breaks the styling. Consider tracking a status type alongside the message.

Proposed approach
-  const [status, setStatus] = useState<string | null>(null);
+  const [status, setStatus] = useState<{ message: string; tone: "error" | "success" | "info" } | null>(null);

Then set it as:

setStatus({ message: "Add at least one file to continue.", tone: "error" });
setStatus({ message: "Job queued. Results will appear below.", tone: "success" });

And derive className directly from status.tone.

Comment on lines +22 to 24
export function proxy(request: Request, event: NextFetchEvent) {
const nextRequest =
request instanceof NextRequest ? request : new NextRequest(request);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if middleware.ts exists and how it relates to proxy.ts
echo "=== Looking for middleware.ts ==="
fd -t f 'middleware\.(ts|js)' apps/web/src/

echo ""
echo "=== Contents of middleware.ts (if found) ==="
fd -t f 'middleware\.(ts|js)' apps/web/src/ --exec cat {}

echo ""
echo "=== Check if proxy.ts is imported anywhere ==="
rg -n "proxy" apps/web/src/middleware.ts 2>/dev/null || echo "No middleware.ts found or no proxy import"

echo ""
echo "=== Check next.config for middleware config ==="
fd -t f 'next.config\.(ts|js|mjs)' apps/web/ --exec cat {}

Repository: rohan-patnaik/ZenPDF

Length of output: 561


🏁 Script executed:

cat -n apps/web/src/proxy.ts

Repository: rohan-patnaik/ZenPDF

Length of output: 1720


Create apps/web/src/middleware.ts to export proxy and config from proxy.ts.

Next.js middleware requires an entry point at middleware.ts (or middleware.js) in the project root or src/ root. The proxy.ts file contains complete middleware logic (Clerk auth protection, header sanitization) and a config.matcher export, but without a middleware.ts that imports and re-exports both proxy and config, Next.js will not recognize this as middleware and none of the code will execute. Clerk auth protection and header sanitization will silently stop working.

Create apps/web/src/middleware.ts:

export { proxy as default, config } from "./proxy";
🤖 Prompt for AI Agents
In `@apps/web/src/proxy.ts` around lines 22 - 24, Create a new middleware entry
file that re-exports the existing middleware function and its matcher so Next.js
will load it: add a `middleware.ts` in the project's src root that imports
`proxy` and `config` from the existing `proxy.ts` and re-exports `proxy` as the
default export and `config` as a named export (i.e., export `{ proxy as default,
config } from "./proxy"`), ensuring the middleware entrypoint is recognized and
Clerk auth/header sanitization runs.

@rohan-patnaik rohan-patnaik merged commit a71e916 into main Feb 6, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant