Skip to content

Prod Release: SEO + V3 Transitivity + GBP + Card Pioneers (flagged)#1691

Merged
Hugo0 merged 97 commits intopeanut-walletfrom
release/prod-2025-02-25
Feb 26, 2026
Merged

Prod Release: SEO + V3 Transitivity + GBP + Card Pioneers (flagged)#1691
Hugo0 merged 97 commits intopeanut-walletfrom
release/prod-2025-02-25

Conversation

@Hugo0
Copy link
Contributor

@Hugo0 Hugo0 commented Feb 25, 2026

Summary

Production release combining multiple feature branches that were cherry-picked/merged into a release branch to bypass KYC 2.0 changes on staging (not ready for prod).

What's included

  • SEO / MDX content pipeline: Programmatic SEO pages with MDX rendering, marketing components (Hero, Steps, FAQ, CTA, CountryGrid), breadcrumbs, FAQ schema, sitemap, i18n locales (es-419, es-ar, pt-br). Content from peanut-content submodule.
  • V3 transitivity UI: Count-up animations on points hero (with localStorage cross-visit memory), scroll-triggered invitee point badge animations via useInView, shared InviteePointsBadge component.
  • Points formatting: formatPoints() (thousands separators) + shortenPoints() (K/M suffix with styled pink span) utilities.
  • Binary cashback: CashCard now shows hasCashbackLeft: boolean status + lifetime earned, instead of dollar allowance.
  • GBP bridge: Sort-code based bridge on/off-ramps (feat: add support for GBP(sort-code) based bridge on/off-ramps #1688).
  • Card Pioneers: Full waitlist frontend, feature-flagged OFF (CARD_PIONEERS_ENABLED = false).
  • Zerodev SDK: Patch bump 5.5.0 → 5.5.7.
  • Hotfix sync: Includes hotfix/loading-screen (fix: kernel client stuck loading screen + retry logic #1685) from prod.
  • Smaller fixes: Sentry filters, squid quote-id, coral v2, manteca deposit warning, currency amounts, cashback tooltip.

Related PRs

Why a release branch instead of staging → prod?

Kushagra's KYC 2.0 rehaul (#1679, #1683, #1689, #1690) landed on peanut-wallet-dev and is not ready for production. This release branch was forked from staging pre-KYC and has the prod-ready features cherry-picked in.

Review Focus Areas

  1. src/hooks/useCountUp.ts — Animation hook with localStorage memory, isAnimating ref to prevent render loops. Had a flickering bug that was fixed.
  2. src/components/Points/CashCard.tsx — Binary cashback display. Verify UX makes sense.
  3. src/app/(mobile-ui)/points/page.tsx — Hero count-up, scroll-triggered invitees, CashCard integration.
  4. SEO components in src/components/Marketing/ — MDX rendering pipeline, ensure no SSR issues.
  5. Card Pioneers in src/app/(mobile-ui)/card/ — Should be completely hidden behind feature flag.
  6. Content submodule at src/content/ — Currently on feat/mdx-components branch (will be pointed to main before merge).

Test Plan

  • /points — hero count-up animation, scroll-triggered invitee badges
  • /points/invites — summary animation, list animations on scroll
  • CashCard shows binary status (has/doesn't have cashback) + lifetime earned
  • SEO pages render (/en/send-money-to/argentina)
  • Card waitlist NOT visible (feature flag off)
  • GBP bridge flows
  • No KYC 2.0 code present
  • pnpm build passes

Hugo0 and others added 30 commits January 29, 2026 14:48
Implements complete Card Pioneers reservation UI with:
- 5-step purchase flow (Info → Details → Geo → Purchase → Success)
- Tier-based pricing display ($10 standard, $9 tier 2 with $1 discount)
- Enhanced Points screen with pending perk claims
- Hold-to-claim interaction with haptic feedback
- Referral graph visible to all users

## Testing Infrastructure
- Playwright E2E tests (6 test suites, 14 scenarios)
- Testing philosophy documentation
- CI integration with automated test runs

## Features
- URL state management with nuqs (page refresh resilient)
- React Query API integration
- Card landing page
- Home carousel CTA for eligible users
- Auth gating on protected routes

## Testing
- 6 E2E test files with 14 scenarios
- Unit tests: 545 passing
- All tests passing

## Breaking Changes
None. Additive changes only.

Related: Backend PR peanutprotocol/peanut-api-ts#555
- Changed onContinue prop to onPurchaseComplete
- Added purchase API integration
- Added loading and error states
- Added navigation to payment page after purchase creation

Fixes TypeScript error: 'onPurchaseComplete' does not exist
FAQsPanel requires heading prop as per component interface
Convert null to undefined to match useWebSocket interface which expects string | undefined
- Fixed Promise.race bug in auth.e2e.test.ts (issue #24)
- Fixed regex that incorrectly matched /card in auth check (issue #25)
- Added proper assertions to placeholder validation test (issue #26)
- All 545 UI tests passing
Signed-off-by: Hugo Montenegro <hugo@peanut.to>
- Fix query key cache inconsistency (add userId to card-info key)
- Display purchase errors to user instead of silent console.error
- Reset claimed perk state on user change
- Fix hydration mismatch in CardPioneers (consistent SSR initial value)
- Add peanut-pioneer.png badge asset
- Add TODO comments for deferred issues (KYC string matching, slots animation)
- Add card-payment page for legacy URL support
- Add clean POST proxy for strict schema validation endpoints
- Strip payment UUID from perk reason display
- Add ?? false fallback for isEligible and hasPurchased props
- Re-enable localStorage dismissal check (was disabled for testing)
- Coerce lastClaimedPerk to boolean in PerkClaimModal
- Make recipient nullable in ParsedURL type for chargeId flows
- Re-export PendingPerk type from websocket service
Card Pioneer and protected routes redirect unauthenticated users to
/setup for onboarding, not /login or /signin. Updated tests to expect
this redirect behavior.
E2E tests were failing because they checked URL immediately after
goto() without waiting for client-side useEffect redirect to occur.
Changed to page.waitForURL() with 10s timeout.
- Add skipBackendCall option to logoutUser for when backend is down
- Extract clearLocalAuthState helper for client-side cleanup
- BackendErrorScreen now uses authContext's logoutUser instead of
  duplicating the logout logic
- Remove internal submissionError state
- Parent component already handles submission errors
- Form now only displays validation errors (required, format)
[TASK-17713] fix: remove duplicate error display in UserDetailsForm
- Warn users to only deposit from their own account
- Matches warning style used in Bridge deposit flow
…ning

[TASK-18329] feat: add third-party deposit warning to Manteca deposit details
…onfigs

[TASK-18290] fix: sentry filters all configs
…onfigs

[TASK-18290] Fix/sentry filters all configs
Copy link
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: 7

🧹 Nitpick comments (14)
src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx (1)

59-59: getAllCategories is called twice for the same locale.

Store the result in a variable to avoid redundant filesystem reads.

Proposed fix
-    const categories = getAllCategories(typedLocale).length > 0 ? getAllCategories(typedLocale) : getAllCategories('en')
+    const localeCategories = getAllCategories(typedLocale)
+    const categories = localeCategories.length > 0 ? localeCategories : getAllCategories('en')
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/(marketing)/blog/category/[cat]/page.tsx at line 59,
getAllCategories is being invoked twice for the same locale; call
getAllCategories(typedLocale) once, store its return in a local variable (e.g.,
const localeCategories = getAllCategories(typedLocale)), then set categories =
localeCategories.length > 0 ? localeCategories : getAllCategories('en'); update
any references to use categories and avoid the duplicate filesystem read.
src/app/[locale]/(marketing)/blog/[slug]/page.tsx (2)

132-135: dangerouslySetInnerHTML with Markdown-rendered HTML — acceptable given controlled content source.

Static analysis correctly flags this (CWE-79). Here the HTML is generated server-side from Markdown files in the peanut-content submodule via marked, so the XSS risk is low as long as the content source remains trusted. If external/user contributions are ever accepted into the content submodule, adding server-side HTML sanitization (e.g. sanitize-html or isomorphic-dompurify) in getPostBySlug would be prudent.

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

In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx around lines 132 - 135,
The code uses dangerouslySetInnerHTML with post.html (rendered via marked) which
is acceptable for trusted Markdown but should be protected if content becomes
external: update getPostBySlug to sanitize the generated HTML before returning
it (e.g., run marked output through a sanitizer like sanitize-html or
isomorphic-dompurify), and keep the page component using dangerouslySetInnerHTML
with the sanitized post.html; reference functions/values:
dangerouslySetInnerHTML, post.html, getPostBySlug, and the Markdown renderer
marked when adding the sanitize step.

69-98: Schema helpers already exist in src/seo/schemas.tsx — consider reusing them.

faqSchema() and breadcrumbSchema() in src/seo/schemas.tsx produce the same JSON-LD structures built manually here (Lines 70-81 and 89-98). Using the shared helpers would reduce duplication and keep schema generation consistent across pages.

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

In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx around lines 69 - 98,
Replace the manual JSON-LD construction with the shared helpers: import and call
the existing faqSchema(...) and breadcrumbSchema(...) helpers (symbols faqSchema
and breadcrumbSchema) instead of building faqSchema and breadcrumbSchema objects
inline; pass the post.frontmatter.faqs (or []/undefined) to faqSchema and the
breadcrumbs array to breadcrumbSchema, and remove the duplicated manual mapping
logic so the page reuses the centralized schema generators.
src/app/[locale]/(marketing)/team/page.tsx (1)

70-77: Consider using next/image instead of native <img>.

Using the native <img> tag bypasses Next.js image optimization (lazy loading, responsive sizing, format negotiation). Since team member images are known at build time, next/image would improve performance and Core Web Vitals on this SEO-focused page.

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

In `@src/app/`[locale]/(marketing)/team/page.tsx around lines 70 - 77, Replace the
native <img> used for team member avatars with Next.js' Image component: import
Image from "next/image" and render <Image src={member.image} alt={member.name}
width={80} height={80} className="rounded-full border border-n-1" /> (or use
appropriate sizes/priority/placeholder props if you have static imports for
member.image); ensure the component consuming member.image (e.g., the map
rendering avatars) passes a valid string or static import and that Image is
given explicit width/height (or responsive sizes) to enable optimization and
lazy loading.
src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx (1)

32-42: Remove redundant canonical parameter from metadataHelper call.

metadataHelper already sets alternates.canonical when a canonical parameter is provided (see metadata.ts line 41). Passing it here and then redefining alternates creates unnecessary duplication. Since you're setting alternates.canonical at the page level anyway, remove the canonical parameter from the metadataHelper call:

Proposed cleanup
     return {
         ...metadataHelper({
             title: fm.title,
             description: fm.description,
-            canonical: `/${locale}/send-money-from/${from}/to/${to}`,
         }),
         alternates: {
             canonical: `/${locale}/send-money-from/${from}/to/${to}`,
             languages: getAlternates('send-money-from', `${from}/to/${to}`),
         },
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx around
lines 32 - 42, The metadata call is duplicating alternates.canonical: remove the
canonical property from the metadataHelper invocation in page.tsx so
metadataHelper({ title: fm.title, description: fm.description }) is used, and
keep the explicit alternates block below (alternates.canonical and
alternates.languages using getAlternates('send-money-from', `${from}/to/${to}`))
to define canonical and language alternates; update the call sites referencing
metadataHelper in this file accordingly.
src/app/sitemap.ts (1)

40-105: Consider deduplicating generated sitemap paths before mapping.

Programmatic sources can accidentally contain duplicate slugs/corridors, which creates duplicate sitemap URLs and unnecessary crawl noise.

Proposed refactor
-    return pages.map((page) => ({
-        url: new URL(page.path || '/', BASE_URL).toString(),
-        lastModified: page.lastModified ?? BUILD_DATE,
-        changeFrequency: page.changeFrequency,
-        priority: page.priority,
-    }))
+    const seen = new Set<string>()
+    return pages
+        .filter((page) => {
+            const key = page.path
+            if (seen.has(key)) return false
+            seen.add(key)
+            return true
+        })
+        .map((page) => ({
+            url: new URL(page.path || '/', BASE_URL).toString(),
+            lastModified: page.lastModified ?? BUILD_DATE,
+            changeFrequency: page.changeFrequency,
+            priority: page.priority,
+        }))

If release timing is tight, this is a good follow-up hardening task rather than a blocker.
Based on learnings Hugo0 often agrees with refactors but defers larger improvements as follow-up issues when timelines are tight.

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

In `@src/app/sitemap.ts` around lines 40 - 105, The sitemap generator can emit
duplicate URLs when programmatic sources (COUNTRIES_SEO, CORRIDORS, COMPETITORS,
EXCHANGES, PAYMENT_METHOD_SLUGS) contain repeated entries; update the logic that
builds the pages array so duplicate paths are removed before returning or
mapping: either deduplicate inputs (e.g., unique arrays for
CORRIDORS/receiveSources) or, after pushing, filter pages by path using a Set of
seen paths (check the pages array and path property) to ensure each path is
emitted only once; adjust functions that reference SUPPORTED_LOCALES and pages
to work with the de-duplicated list.
src/app/(mobile-ui)/points/page.tsx (4)

118-127: Extract shortenPoints result to a variable to avoid the IIFE inside JSX.

The IIFE works but reduces readability and makes the JSX harder to scan. A simple destructuring before the return statement is more idiomatic.

♻️ Proposed refactor
+    const { number: pointsNumber, suffix: pointsSuffix } = shortenPoints(animatedTotal)

     return (
         ...
         <h2 className="text-4xl font-black text-black">
-            {(() => {
-                const { number, suffix } = shortenPoints(animatedTotal)
-                return (
-                    <>
-                        {number}
-                        {suffix && <span className="text-primary-1">{suffix}</span>}
-                    </>
-                )
-            })()}{' '}
+            {pointsNumber}
+            {pointsSuffix && <span className="text-primary-1">{pointsSuffix}</span>}{' '}
             {tierInfo.data.totalPoints === 1 ? 'Point' : 'Points'}
         </h2>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/points/page.tsx around lines 118 - 127, Extract the IIFE
result into a local variable before the JSX return: call
shortenPoints(animatedTotal) in the component body (or just above the return)
and destructure const { number, suffix } = shortenPoints(animatedTotal); then
replace the IIFE block in the JSX with {number}{suffix && <span
className="text-primary-1">{suffix}</span>} and keep the existing points label
that checks tierInfo.data.totalPoints. This removes the self-invoking function
and improves readability while keeping shortenPoints and animatedTotal usage
intact.

88-91: Add an eslint-disable comment for the intentionally empty dependency array.

fetchUser is omitted from the useEffect deps by design (fire-once on mount), but without a suppression comment the linter will flag it, and a future maintainer may accidentally "fix" it and introduce an infinite loop.

🛠️ Proposed fix
     useEffect(() => {
         // re-fetch user to get the latest invitees list for showing heart icon
         fetchUser()
-    }, [])
+        // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional: run once on mount
+    }, [])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/points/page.tsx around lines 88 - 91, The useEffect that
calls fetchUser intentionally omits fetchUser from its dependency array (to run
once on mount) but lacks an eslint suppression comment; update the useEffect
containing fetchUser() to add an appropriate
eslint-disable-next-line/react-hooks/exhaustive-deps (or the inline /*
eslint-disable-next-line react-hooks/exhaustive-deps */) comment immediately
above the dependency array so the linter won’t warn and future maintainers won’t
accidentally reintroduce fetchUser into the deps.

217-217: Redundant invites && guard — invites?.invitees already handles the null case.

invites?.invitees short-circuits if invites is nullish, so the leading invites && is superfluous.

🛠️ Proposed fix
-            {invites && invites?.invitees && invites.invitees.length > 0 ? (
+            {invites?.invitees && invites.invitees.length > 0 ? (
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/points/page.tsx at line 217, Remove the redundant
"invites &&" guard in the conditional rendering that currently reads with both
invites and invites?.invitees; rely on the existing nullish-safe check
invites?.invitees and keep the length check (invites?.invitees.length > 0) so
the JSX conditional (the expression using invites and invites?.invitees) becomes
just using invites?.invitees and its length to decide rendering.

36-37: Type inviteesRef explicitly — same React 19 / TypeScript concern as in invites/page.tsx.

🛠️ Proposed fix
-    const inviteesRef = useRef(null)
+    const inviteesRef = useRef<HTMLDivElement>(null)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/points/page.tsx around lines 36 - 37, inviteesRef is
currently untyped which can cause React 19/TypeScript inference issues;
explicitly type the ref used with useRef and match the pattern used in
invites/page.tsx — e.g., change the declaration of inviteesRef to
useRef<HTMLDivElement | null>(null) (or the actual element/component type you
attach) and keep useInView(inviteesRef, { once: true, margin: '-50px' }) as-is
so TypeScript knows the ref element type for inviteesRef and avoids inference
errors.
src/app/(mobile-ui)/points/invites/page.tsx (1)

28-29: Type listRef explicitly to avoid a React 19 / TypeScript incompatibility.

useRef(null) without a type argument infers RefObject<null>, which is incompatible with the HTMLDivElement ref expected by the div at line 85 and by framer-motion's useInView. In React 19, useRef<T>(null) returns RefObject<T | null>.

🛠️ Proposed fix
-    const listRef = useRef(null)
+    const listRef = useRef<HTMLDivElement>(null)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/points/invites/page.tsx around lines 28 - 29, Type
listRef explicitly as a RefObject<HTMLDivElement | null> instead of useRef(null)
so its type matches the div ref and framer-motion's useInView; update the
declaration of listRef (the variable passed to useInView and attached to the
div) to use useRef<HTMLDivElement | null>(null) so the ref and listInView typing
are compatible with React 19 and TypeScript.
src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx (3)

26-27: seo is declared but never used — simplify the guard.

COUNTRIES_SEO[country] is only accessed to check existence; the value is never read. This leaves a dead variable.

♻️ Proposed fix
-    const seo = COUNTRIES_SEO[country]
-    if (!seo) return {}
+    if (!COUNTRIES_SEO[country]) return {}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/(marketing)/send-money-to/[country]/page.tsx around lines
26 - 27, Remove the unused local variable seo and directly check the map for
existence: replace the two-line pattern that assigns seo =
COUNTRIES_SEO[country] then guards with a single guard that tests
COUNTRIES_SEO[country] (or uses
Object.prototype.hasOwnProperty.call(COUNTRIES_SEO, country)) and returns {}
when absent; update any references that relied on seo to instead access
COUNTRIES_SEO[country] if needed.

49-50: Missing generic type on readPageContentLocalized weakens type safety for published guard.

Line 29 in generateMetadata correctly annotates the call with <{ title: string; description: string; published?: boolean }>, but the page component's call on line 49 is untyped. Without it, frontmatter is Record<string, unknown>, so mdxSource.frontmatter.published is unknown. The === false comparison still works at runtime, but TypeScript won't catch any future shape mismatches.

♻️ Proposed fix
-    const mdxSource = readPageContentLocalized('send-to', country, locale)
+    const mdxSource = readPageContentLocalized<{ title: string; description: string; published?: boolean }>('send-to', country, locale)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/(marketing)/send-money-to/[country]/page.tsx around lines
49 - 50, The call to readPageContentLocalized in the page component is missing
the generic type, causing mdxSource.frontmatter to be typed as Record<string,
unknown>; update the call to use the same generic used in generateMetadata (e.g.
<{ title: string; description: string; published?: boolean }>) so
mdxSource.frontmatter.published is properly typed and the published guard is
type-safe; locate the call to readPageContentLocalized and add the generic
parameter accordingly to preserve typings for mdxSource.frontmatter.published.

36-40: Redundant canonical handling, but metadataBase is configured and resolution works correctly.

The original concern about metadataBase being absent is incorrect. It's configured both in the root layout (src/app/layout.tsx:17) and within metadataHelper itself (src/app/metadata.ts:21), so relative canonical URLs will resolve to absolute URLs correctly.

However, there is a minor code quality issue: the canonical is passed to metadataHelper (which returns it in alternates.canonical) and then immediately overridden by explicitly setting alternates again with the same canonical value. This is redundant, though it's a consistent pattern across all similar pages in the codebase.

The override is intentional to add the languages object from getAlternates, but cleaner approaches exist:

  • Don't pass canonical to metadataHelper, only set it in the page's explicit alternates block, or
  • Modify metadataHelper to merge language alternates instead of forcing pages to override them.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/(marketing)/send-money-to/[country]/page.tsx around lines
36 - 40, The canonical URL is being redundantly provided to metadataHelper and
then immediately overridden in alternates; update the page so it either (A) stop
passing the canonical into metadataHelper and let the page set
alternates.canonical explicitly while still adding languages via getAlternates,
or (B) update metadataHelper to accept/merge a languages object so callers only
pass the path and metadataHelper returns alternates with both canonical and
languages. Locate usages of metadataHelper in this file (the call that returns
metadata/alternates) and the getAlternates call, and implement option A (remove
the canonical argument from metadataHelper call and keep alternates: {
canonical: `/${locale}/send-money-to/${country}`, languages:
getAlternates('send-money-to', country) }) or implement option B in
metadataHelper to merge languages into alternates so the page no longer needs to
override alternates.canonical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx:
- Around line 100-138: Run the project's Prettier formatter on this file to
resolve CI style violations: run pnpm prettier --write for
src/app/[locale]/(marketing)/blog/[slug]/page.tsx (or your editor's format tool)
so the JSX around JsonLd, MarketingShell, the breadcrumbs map, header markup
(post.frontmatter.title/description/date) and the article with
dangerouslySetInnerHTML are formatted to the repo Prettier config; commit the
formatted file to clear the Prettier errors.
- Line 55: After the isValidLocale(locale) guard, locale is still typed as
string but getTranslations expects a Locale; update the call to getTranslations
to cast locale to the Locale union (e.g. getTranslations(locale as Locale)).
Locate the call to getTranslations in page.tsx (symbol: getTranslations) and add
the as Locale cast to the locale argument so TypeScript recognizes the narrowed
type and the strict signature is satisfied.

In `@src/app/`[locale]/(marketing)/blog/category/[cat]/page.tsx:
- Around line 72-82: The category chips are rendering the raw slug variable c in
the Link instead of the human-readable label used in the hero; update the
mapping inside categories.map (where c and cat are used) to render the
slug-to-label transformation used by the hero (e.g., replace hyphens with spaces
and title-case each word) so the Link displays the human-readable label while
keeping href as `/${locale}/blog/category/${c}` and preserving the key as c.

In `@src/app/`[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx:
- Line 53: renderContent(mdxSource.body) can throw from compileMDX and will
crash SSR; wrap the call to renderContent in a try/catch around the await in the
Server Component (the code that calls renderContent) and on error call
notFound() (or return a graceful error payload) instead of letting the exception
bubble; reference the renderContent invocation and handle exceptions from
compileMDX, ensuring the catch path triggers notFound() so
ContentPage/MarketingErrorBoundary no longer needs to catch SSR throws.

In `@src/app/sitemap.ts`:
- Around line 107-109: The sitemap entry URL building uses string concatenation
(in the pages.map mapping) which can create malformed URLs when BASE_URL ends
with a slash; change construction to use the URL API (e.g., new URL(page.path,
BASE_URL).toString()) for the url field so paths are normalized and trailing
slashes handled correctly, keeping the lastModified fallback to BUILD_DATE
unchanged.

In `@src/components/LandingPage/LandingPageClient.tsx`:
- Around line 156-158: The handleTouchStart handler reads event.touches[0]
unguarded and can throw if event.touches is empty; update handleTouchStart to
first check event.touches && event.touches.length > 0 (or use optional chaining
and length check) before reading clientY and only set touchStartY.current when a
touch exists, otherwise leave it unchanged or set to null/undefined as
appropriate to existing touch handling logic.
- Around line 26-30: The faqData.marquee.message is typed as string but
MarqueeComp (used inside FAQs) expects message: string[]; change the faqData
type so marquee.message is string[] and ensure wherever faqData is constructed
or passed (e.g., LandingPageClient's faqData initializer and any props into
FAQs) you supply an array of strings; update any default values or tests that
build faqData to use arrays and keep MarqueeComp usage (MarqueeComp, FAQs,
faqData.marquee.message) consistent.

---

Nitpick comments:
In `@src/app/`(mobile-ui)/points/invites/page.tsx:
- Around line 28-29: Type listRef explicitly as a RefObject<HTMLDivElement |
null> instead of useRef(null) so its type matches the div ref and
framer-motion's useInView; update the declaration of listRef (the variable
passed to useInView and attached to the div) to use useRef<HTMLDivElement |
null>(null) so the ref and listInView typing are compatible with React 19 and
TypeScript.

In `@src/app/`(mobile-ui)/points/page.tsx:
- Around line 118-127: Extract the IIFE result into a local variable before the
JSX return: call shortenPoints(animatedTotal) in the component body (or just
above the return) and destructure const { number, suffix } =
shortenPoints(animatedTotal); then replace the IIFE block in the JSX with
{number}{suffix && <span className="text-primary-1">{suffix}</span>} and keep
the existing points label that checks tierInfo.data.totalPoints. This removes
the self-invoking function and improves readability while keeping shortenPoints
and animatedTotal usage intact.
- Around line 88-91: The useEffect that calls fetchUser intentionally omits
fetchUser from its dependency array (to run once on mount) but lacks an eslint
suppression comment; update the useEffect containing fetchUser() to add an
appropriate eslint-disable-next-line/react-hooks/exhaustive-deps (or the inline
/* eslint-disable-next-line react-hooks/exhaustive-deps */) comment immediately
above the dependency array so the linter won’t warn and future maintainers won’t
accidentally reintroduce fetchUser into the deps.
- Line 217: Remove the redundant "invites &&" guard in the conditional rendering
that currently reads with both invites and invites?.invitees; rely on the
existing nullish-safe check invites?.invitees and keep the length check
(invites?.invitees.length > 0) so the JSX conditional (the expression using
invites and invites?.invitees) becomes just using invites?.invitees and its
length to decide rendering.
- Around line 36-37: inviteesRef is currently untyped which can cause React
19/TypeScript inference issues; explicitly type the ref used with useRef and
match the pattern used in invites/page.tsx — e.g., change the declaration of
inviteesRef to useRef<HTMLDivElement | null>(null) (or the actual
element/component type you attach) and keep useInView(inviteesRef, { once: true,
margin: '-50px' }) as-is so TypeScript knows the ref element type for
inviteesRef and avoids inference errors.

In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx:
- Around line 132-135: The code uses dangerouslySetInnerHTML with post.html
(rendered via marked) which is acceptable for trusted Markdown but should be
protected if content becomes external: update getPostBySlug to sanitize the
generated HTML before returning it (e.g., run marked output through a sanitizer
like sanitize-html or isomorphic-dompurify), and keep the page component using
dangerouslySetInnerHTML with the sanitized post.html; reference
functions/values: dangerouslySetInnerHTML, post.html, getPostBySlug, and the
Markdown renderer marked when adding the sanitize step.
- Around line 69-98: Replace the manual JSON-LD construction with the shared
helpers: import and call the existing faqSchema(...) and breadcrumbSchema(...)
helpers (symbols faqSchema and breadcrumbSchema) instead of building faqSchema
and breadcrumbSchema objects inline; pass the post.frontmatter.faqs (or
[]/undefined) to faqSchema and the breadcrumbs array to breadcrumbSchema, and
remove the duplicated manual mapping logic so the page reuses the centralized
schema generators.

In `@src/app/`[locale]/(marketing)/blog/category/[cat]/page.tsx:
- Line 59: getAllCategories is being invoked twice for the same locale; call
getAllCategories(typedLocale) once, store its return in a local variable (e.g.,
const localeCategories = getAllCategories(typedLocale)), then set categories =
localeCategories.length > 0 ? localeCategories : getAllCategories('en'); update
any references to use categories and avoid the duplicate filesystem read.

In `@src/app/`[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx:
- Around line 32-42: The metadata call is duplicating alternates.canonical:
remove the canonical property from the metadataHelper invocation in page.tsx so
metadataHelper({ title: fm.title, description: fm.description }) is used, and
keep the explicit alternates block below (alternates.canonical and
alternates.languages using getAlternates('send-money-from', `${from}/to/${to}`))
to define canonical and language alternates; update the call sites referencing
metadataHelper in this file accordingly.

In `@src/app/`[locale]/(marketing)/send-money-to/[country]/page.tsx:
- Around line 26-27: Remove the unused local variable seo and directly check the
map for existence: replace the two-line pattern that assigns seo =
COUNTRIES_SEO[country] then guards with a single guard that tests
COUNTRIES_SEO[country] (or uses
Object.prototype.hasOwnProperty.call(COUNTRIES_SEO, country)) and returns {}
when absent; update any references that relied on seo to instead access
COUNTRIES_SEO[country] if needed.
- Around line 49-50: The call to readPageContentLocalized in the page component
is missing the generic type, causing mdxSource.frontmatter to be typed as
Record<string, unknown>; update the call to use the same generic used in
generateMetadata (e.g. <{ title: string; description: string; published?:
boolean }>) so mdxSource.frontmatter.published is properly typed and the
published guard is type-safe; locate the call to readPageContentLocalized and
add the generic parameter accordingly to preserve typings for
mdxSource.frontmatter.published.
- Around line 36-40: The canonical URL is being redundantly provided to
metadataHelper and then immediately overridden in alternates; update the page so
it either (A) stop passing the canonical into metadataHelper and let the page
set alternates.canonical explicitly while still adding languages via
getAlternates, or (B) update metadataHelper to accept/merge a languages object
so callers only pass the path and metadataHelper returns alternates with both
canonical and languages. Locate usages of metadataHelper in this file (the call
that returns metadata/alternates) and the getAlternates call, and implement
option A (remove the canonical argument from metadataHelper call and keep
alternates: { canonical: `/${locale}/send-money-to/${country}`, languages:
getAlternates('send-money-to', country) }) or implement option B in
metadataHelper to merge languages into alternates so the page no longer needs to
override alternates.canonical.

In `@src/app/`[locale]/(marketing)/team/page.tsx:
- Around line 70-77: Replace the native <img> used for team member avatars with
Next.js' Image component: import Image from "next/image" and render <Image
src={member.image} alt={member.name} width={80} height={80}
className="rounded-full border border-n-1" /> (or use appropriate
sizes/priority/placeholder props if you have static imports for member.image);
ensure the component consuming member.image (e.g., the map rendering avatars)
passes a valid string or static import and that Image is given explicit
width/height (or responsive sizes) to enable optimization and lazy loading.

In `@src/app/sitemap.ts`:
- Around line 40-105: The sitemap generator can emit duplicate URLs when
programmatic sources (COUNTRIES_SEO, CORRIDORS, COMPETITORS, EXCHANGES,
PAYMENT_METHOD_SLUGS) contain repeated entries; update the logic that builds the
pages array so duplicate paths are removed before returning or mapping: either
deduplicate inputs (e.g., unique arrays for CORRIDORS/receiveSources) or, after
pushing, filter pages by path using a Set of seen paths (check the pages array
and path property) to ensure each path is emitted only once; adjust functions
that reference SUPPORTED_LOCALES and pages to work with the de-duplicated list.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 580887b and c9561fa.

📒 Files selected for processing (17)
  • src/app/(mobile-ui)/home/page.tsx
  • src/app/(mobile-ui)/points/invites/page.tsx
  • src/app/(mobile-ui)/points/page.tsx
  • src/app/[locale]/(marketing)/blog/[slug]/page.tsx
  • src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx
  • src/app/[locale]/(marketing)/blog/page.tsx
  • src/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx
  • src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
  • src/app/[locale]/(marketing)/team/page.tsx
  • src/app/actions/card.ts
  • src/app/sitemap.ts
  • src/components/Card/CardGeoScreen.tsx
  • src/components/LandingPage/LandingPageClient.tsx
  • src/components/LandingPage/LandingPageShell.tsx
  • src/components/Marketing/mdx/RelatedPages.tsx
  • src/content
  • src/data/seo/index.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/app/[locale]/(marketing)/blog/page.tsx
  • src/components/Card/CardGeoScreen.tsx
  • src/app/actions/card.ts

const post = (await getPostBySlug(slug, locale as Locale)) ?? (await getPostBySlug(slug, 'en'))
if (!post) notFound()

const i18n = getTranslations(locale)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing as Locale cast on locale passed to getTranslations.

After the isValidLocale guard on Line 50, locale is logically a Locale, but it's still typed as string. The category page and team page both cast locale as Locale before calling getTranslations. This will produce a TypeScript error if getTranslations has a strict Locale parameter type.

Proposed fix
-    const i18n = getTranslations(locale)
+    const i18n = getTranslations(locale as Locale)
📝 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
const i18n = getTranslations(locale)
const i18n = getTranslations(locale as Locale)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx at line 55, After the
isValidLocale(locale) guard, locale is still typed as string but getTranslations
expects a Locale; update the call to getTranslations to cast locale to the
Locale union (e.g. getTranslations(locale as Locale)). Locate the call to
getTranslations in page.tsx (symbol: getTranslations) and add the as Locale cast
to the locale argument so TypeScript recognizes the narrowed type and the strict
signature is satisfied.

Comment on lines +72 to +82
{categories.map((c) => (
<Link
key={c}
href={`/${locale}/blog/category/${c}`}
className={`rounded-sm border border-n-1 px-3 py-1 text-sm ${
c === cat ? 'bg-primary-1/20 font-semibold' : 'hover:bg-primary-3/30'
}`}
>
{c}
</Link>
))}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Category chips display raw slugs instead of human-readable labels.

The hero (Line 63) shows the title-cased label, but the category chips render {c} which is the raw slug (e.g. crypto-payments instead of Crypto Payments). Apply the same slug-to-label transformation for consistency.

Proposed fix
                     {categories.map((c) => (
                         <Link
                             key={c}
                             href={`/${locale}/blog/category/${c}`}
                             className={`rounded-sm border border-n-1 px-3 py-1 text-sm ${
                                 c === cat ? 'bg-primary-1/20 font-semibold' : 'hover:bg-primary-3/30'
                             }`}
                         >
-                            {c}
+                            {c.replace(/-/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())}
                         </Link>
                     ))}
📝 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
{categories.map((c) => (
<Link
key={c}
href={`/${locale}/blog/category/${c}`}
className={`rounded-sm border border-n-1 px-3 py-1 text-sm ${
c === cat ? 'bg-primary-1/20 font-semibold' : 'hover:bg-primary-3/30'
}`}
>
{c}
</Link>
))}
{categories.map((c) => (
<Link
key={c}
href={`/${locale}/blog/category/${c}`}
className={`rounded-sm border border-n-1 px-3 py-1 text-sm ${
c === cat ? 'bg-primary-1/20 font-semibold' : 'hover:bg-primary-3/30'
}`}
>
{c.replace(/-/g, ' ').replace(/\b\w/g, (ch) => ch.toUpperCase())}
</Link>
))}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/(marketing)/blog/category/[cat]/page.tsx around lines 72 -
82, The category chips are rendering the raw slug variable c in the Link instead
of the human-readable label used in the hero; update the mapping inside
categories.map (where c and cat are used) to render the slug-to-label
transformation used by the hero (e.g., replace hyphens with spaces and
title-case each word) so the Link displays the human-readable label while
keeping href as `/${locale}/blog/category/${c}` and preserving the key as c.

const mdxSource = readCorridorContentLocalized(to, from, locale)
if (!mdxSource || mdxSource.frontmatter.published === false) notFound()

const { content } = await renderContent(mdxSource.body)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Unguarded renderContent call will crash the page on malformed MDX.

compileMDX (called inside renderContent) throws synchronously-async on parse or compilation errors. Since this is a Server Component, no React error boundary can catch it — the request will result in a 500. ContentPage's MarketingErrorBoundary only catches client-side render errors after hydration, not SSR throws.

Wrap renderContent in a try/catch and fall back to notFound() (or a graceful error state):

🛡️ Proposed fix
-    const { content } = await renderContent(mdxSource.body)
+    let content: React.ReactElement
+    try {
+        ;({ content } = await renderContent(mdxSource.body))
+    } catch {
+        notFound()
+    }
📝 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
const { content } = await renderContent(mdxSource.body)
let content: React.ReactElement
try {
;({ content } = await renderContent(mdxSource.body))
} catch {
notFound()
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsx at line
53, renderContent(mdxSource.body) can throw from compileMDX and will crash SSR;
wrap the call to renderContent in a try/catch around the await in the Server
Component (the code that calls renderContent) and on error call notFound() (or
return a graceful error payload) instead of letting the exception bubble;
reference the renderContent invocation and handle exceptions from compileMDX,
ensuring the catch path triggers notFound() so
ContentPage/MarketingErrorBoundary no longer needs to catch SSR throws.

Comment on lines +107 to +109
return pages.map((page) => ({
url: `${BASE_URL}${page.path}`,
lastModified: page.lastModified ?? BUILD_DATE,
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Use safe URL joining for sitemap entries.

String concatenation can produce malformed URLs when BASE_URL has a trailing slash. Build URLs with URL to normalize reliably.

Proposed fix
-    return pages.map((page) => ({
-        url: `${BASE_URL}${page.path}`,
+    return pages.map((page) => ({
+        url: new URL(page.path || '/', BASE_URL).toString(),
         lastModified: page.lastModified ?? BUILD_DATE,
         changeFrequency: page.changeFrequency,
         priority: page.priority,
     }))
📝 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
return pages.map((page) => ({
url: `${BASE_URL}${page.path}`,
lastModified: page.lastModified ?? BUILD_DATE,
return pages.map((page) => ({
url: new URL(page.path || '/', BASE_URL).toString(),
lastModified: page.lastModified ?? BUILD_DATE,
changeFrequency: page.changeFrequency,
priority: page.priority,
}))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/sitemap.ts` around lines 107 - 109, The sitemap entry URL building
uses string concatenation (in the pages.map mapping) which can create malformed
URLs when BASE_URL ends with a slash; change construction to use the URL API
(e.g., new URL(page.path, BASE_URL).toString()) for the url field so paths are
normalized and trailing slashes handled correctly, keeping the lastModified
fallback to BUILD_DATE unchanged.

Comment on lines +26 to +30
faqData: {
heading: string
questions: FAQQuestion[]
marquee: { visible: boolean; message: string }
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check the actual code at lines 26-30 in LandingPageClient.tsx
cat -n src/components/LandingPage/LandingPageClient.tsx | sed -n '20,40p'

Repository: peanutprotocol/peanut-ui

Length of output: 675


🏁 Script executed:

# Search for FAQs and Marquee component definitions and their prop types
rg -n "interface.*FAQs|interface.*LocalFAQsProps|interface.*MarqueeProps|interface.*MarqueeComp" src/components/LandingPage/ -A5

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

# Search for MarqueeComp and message prop specifically
rg -n "message" src/components/LandingPage/marquee.tsx -B2 -A2

Repository: peanutprotocol/peanut-ui

Length of output: 593


🏁 Script executed:

# Check how FAQs component uses the marquee prop
rg -n "marquee" src/components/LandingPage/faq.tsx -B2 -A2

Repository: peanutprotocol/peanut-ui

Length of output: 617


🏁 Script executed:

# Verify how faqData is constructed and passed to FAQs component
rg -n "faqData.*=" src/components/LandingPage/LandingPageClient.tsx -B2 -A10

Repository: peanutprotocol/peanut-ui

Length of output: 354


🏁 Script executed:

# Check the FAQs component invocation in LandingPageClient
rg -n "FAQs|<FAQs" src/components/LandingPage/LandingPageClient.tsx -B2 -A5

Repository: peanutprotocol/peanut-ui

Length of output: 790


faqData.marquee.message is typed as string but MarqueeComp expects string[].

MarqueeComp expects a message prop of type string[] (default: ['No fees', 'Instant', '24/7', ...]). When faqData.marquee is passed through FAQs to MarqueeComp, passing a single string would be iterated character-by-character instead of as an array of words.

🐛 Proposed fix
     faqData: {
         heading: string
         questions: FAQQuestion[]
-        marquee: { visible: boolean; message: string }
+        marquee: { visible: boolean; message: string[] }
     }
📝 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
faqData: {
heading: string
questions: FAQQuestion[]
marquee: { visible: boolean; message: string }
}
faqData: {
heading: string
questions: FAQQuestion[]
marquee: { visible: boolean; message: string[] }
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/LandingPage/LandingPageClient.tsx` around lines 26 - 30, The
faqData.marquee.message is typed as string but MarqueeComp (used inside FAQs)
expects message: string[]; change the faqData type so marquee.message is
string[] and ensure wherever faqData is constructed or passed (e.g.,
LandingPageClient's faqData initializer and any props into FAQs) you supply an
array of strings; update any default values or tests that build faqData to use
arrays and keep MarqueeComp usage (MarqueeComp, FAQs, faqData.marquee.message)
consistent.

Comment on lines +156 to +158
const handleTouchStart = (event: TouchEvent) => {
touchStartY.current = event.touches[0].clientY
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Guard event.touches[0] access against empty touches list.

touchstart can fire with event.touches.length === 0 in certain multi-touch cancel scenarios. Accessing event.touches[0] unconditionally will throw.

🛡️ Proposed fix
         const handleTouchStart = (event: TouchEvent) => {
-            touchStartY.current = event.touches[0].clientY
+            if (event.touches.length > 0) {
+                touchStartY.current = event.touches[0].clientY
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/LandingPage/LandingPageClient.tsx` around lines 156 - 158, The
handleTouchStart handler reads event.touches[0] unguarded and can throw if
event.touches is empty; update handleTouchStart to first check event.touches &&
event.touches.length > 0 (or use optional chaining and length check) before
reading clientY and only set touchStartY.current when a touch exists, otherwise
leave it unchanged or set to null/undefined as appropriate to existing touch
handling logic.

Button was disabled with no spinner while user data was being fetched
during setup, making the UI feel stuck. Now shows spinner + "Loading..."
until user is available.
Copy link
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

♻️ Duplicate comments (1)
src/app/[locale]/(marketing)/blog/[slug]/page.tsx (1)

55-55: ⚠️ Potential issue | 🟡 Minor

Missing as Locale cast on locale passed to getTranslations.

After the isValidLocale guard on Line 50, locale is logically a Locale, but TypeScript still types it as string. The getTranslations function signature expects Locale (see src/i18n/index.ts). This may cause a TypeScript error.

Proposed fix
-    const i18n = getTranslations(locale)
+    const i18n = getTranslations(locale as Locale)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx at line 55, After the
isValidLocale check, TypeScript still treats locale as string; update the call
to getTranslations so the locale is cast to the Locale type (e.g., pass locale
as Locale) to match the getTranslations signature. Locate the call to
getTranslations in page.tsx (after the isValidLocale guard) and change the
argument to a casted Locale to eliminate the type error while keeping the
runtime guard intact.
🧹 Nitpick comments (10)
src/components/Marketing/mdx/ExchangeWidget.tsx (1)

58-60: Consider encoding query parameters.

The currency codes are directly interpolated into the URL. While ISO 4217 codes are typically safe alphanumeric strings, using encodeURIComponent or URLSearchParams would be more defensive.

♻️ Suggested improvement
                     ctaAction={(from, to) => {
-                        router.push(`/send?from=${from}&to=${to}`)
+                        const params = new URLSearchParams({ from, to })
+                        router.push(`/send?${params.toString()}`)
                     }}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Marketing/mdx/ExchangeWidget.tsx` around lines 58 - 60, Update
the ctaAction passed to ExchangeWidget so query values are URL-encoded before
navigation: instead of interpolating from and to directly in router.push, encode
them (e.g., via encodeURIComponent or building a URLSearchParams) to produce a
safe query string; locate the ctaAction handler in ExchangeWidget.tsx where
router.push(`/send?from=${from}&to=${to}`) is used and replace the interpolation
with an encoded query string using the chosen encoding method.
src/components/Marketing/ContentPage.tsx (1)

20-29: Consider reusing the existing breadcrumbSchema helper from src/lib/seo/schemas.tsx.

The relevant code snippets show a breadcrumbSchema(items) helper already exists in src/lib/seo/schemas.tsx. This component duplicates that logic with a slightly different interface (href vs url). Consider normalizing the interface and reusing the helper to reduce duplication.

However, the existing helper always prefixes with baseUrl, while this implementation conditionally handles absolute URLs starting with http. If that conditional behavior is intentional, the existing helper could be extended instead.

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

In `@src/components/Marketing/ContentPage.tsx` around lines 20 - 29, This
component duplicates breadcrumb construction; replace the local const
breadcrumbSchema with the shared breadcrumbSchema(items) helper from
src/lib/seo/schemas.tsx by normalizing the items you pass (convert each {name,
href} to the helper's expected shape, e.g., {name, url}) or update the shared
breadcrumbSchema helper to accept href and preserve the existing absolute-URL
behavior (detect strings starting with "http" and avoid prefixing BASE_URL) so
the component can import and reuse breadcrumbSchema(items) instead of
reimplementing it.
src/app/[locale]/(marketing)/blog/[slug]/page.tsx (2)

106-124: Consider extracting breadcrumb navigation into a shared component.

This breadcrumb rendering logic is nearly identical to ContentPage.tsx (lines 37-55). If more pages adopt this pattern, extracting a <BreadcrumbNav breadcrumbs={...} /> component would reduce duplication.

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

In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx around lines 106 - 124,
Extract the repeated breadcrumb JSX into a reusable component (e.g.,
BreadcrumbNav) and replace the inline rendering in page.tsx and ContentPage.tsx
with <BreadcrumbNav breadcrumbs={breadcrumbs} />; move the nav/ol/li structure
and Link handling into the new BreadcrumbNav component, accept a breadcrumbs
prop (array with href/name), preserve the aria-label, classes, truncation, and
key usage (key={crumb.href}), and export/import the component in both files to
remove duplication.

66-66: Use BASE_URL constant instead of hardcoded https://peanut.me.

Lines 66 and 96 hardcode the domain, while ContentPage.tsx uses the BASE_URL constant for the same purpose. Using the constant ensures consistency and makes environment-specific overrides easier.

Proposed fix

Add the import:

 import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config'
+import { BASE_URL } from '@/constants/general.consts'

Then update the usages:

-        mainEntityOfPage: `https://peanut.me/${locale}/blog/${slug}`,
+        mainEntityOfPage: `${BASE_URL}/${locale}/blog/${slug}`,
-            item: crumb.href.startsWith('http') ? crumb.href : `https://peanut.me${crumb.href}`,
+            item: crumb.href.startsWith('http') ? crumb.href : `${BASE_URL}${crumb.href}`,

Also applies to: 96-96

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

In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx at line 66, Replace the
hardcoded domain with the shared BASE_URL constant: import BASE_URL at the top
of the file and use it to build the URLs where you currently have
`https://peanut.me` (specifically the properties that set mainEntityOfPage and
the other URL at the second occurrence around line 96 in page.tsx) so the
expressions become template strings based on BASE_URL (e.g.,
`${BASE_URL}/${locale}/blog/${slug}`) instead of the hardcoded domain.
src/app/(mobile-ui)/points/page.tsx (4)

88-91: Consider adding fetchUser to the dependency array or using an eslint-disable comment.

The empty dependency array triggers a lint warning (exhaustive-deps) since fetchUser is called but not listed. While this "run once on mount" pattern is intentional and fetchUser is likely stable from context, adding an explicit comment clarifies intent:

     useEffect(() => {
         // re-fetch user to get the latest invitees list for showing heart icon
         fetchUser()
+        // eslint-disable-next-line react-hooks/exhaustive-deps -- intentional mount-only effect
     }, [])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/points/page.tsx around lines 88 - 91, The useEffect
calling fetchUser in page.tsx currently has an empty dependency array and
triggers exhaustive-deps warnings; update the effect to either include fetchUser
in the dependency array (useEffect(() => { fetchUser() }, [fetchUser])) or, if
fetchUser is stable and you intentionally want to run it only once, add an
inline ESLint disable comment above the effect (// eslint-disable-next-line
react-hooks/exhaustive-deps) with a brief note explaining that fetchUser is
stable and the effect should run once; reference the useEffect that calls
fetchUser to apply the chosen change.

302-306: Verify empty username handling in InviteFriendsModal.

username ?? '' passes an empty string if username is undefined. The modal uses this to generate invite codes via generateInviteCodeLink(username). If the user is authenticated (which they must be to reach this page), username should always be defined, but consider adding a guard to prevent rendering the modal with invalid state.

🛡️ Optional defensive check
                {/* Invite Modal */}
+               {username && (
                <InviteFriendsModal
                    visible={isInviteModalOpen}
                    onClose={() => setIsInviteModalOpen(false)}
-                   username={username ?? ''}
+                   username={username}
                />
+               )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/points/page.tsx around lines 302 - 306, The
InviteFriendsModal is being rendered with username ?? '' which allows an empty
string to reach generateInviteCodeLink; instead, guard rendering by only opening
or mounting InviteFriendsModal when a valid username exists—e.g., conditionally
render InviteFriendsModal when isInviteModalOpen && username (or check inside
the click handler before setIsInviteModalOpen(true)); ensure
generateInviteCodeLink(username) is only called with a non-empty username
(references: InviteFriendsModal, generateInviteCodeLink, username,
isInviteModalOpen, setIsInviteModalOpen).

118-128: Consider extracting the shortened points to a variable for cleaner JSX.

The IIFE pattern works but can be simplified:

♻️ Optional refactor
+                       const shortened = shortenPoints(animatedTotal)
                        <h2 className="text-4xl font-black text-black">
-                           {(() => {
-                               const { number, suffix } = shortenPoints(animatedTotal)
-                               return (
-                                   <>
-                                       {number}
-                                       {suffix && <span className="text-primary-1">{suffix}</span>}
-                                   </>
-                               )
-                           })()}{' '}
+                           {shortened.number}
+                           {shortened.suffix && <span className="text-primary-1">{shortened.suffix}</span>}{' '}
                            {tierInfo.data.totalPoints === 1 ? 'Point' : 'Points'}
                        </h2>

Move the shortenPoints(animatedTotal) call above the JSX return for better readability.

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

In `@src/app/`(mobile-ui)/points/page.tsx around lines 118 - 128, The JSX contains
an IIFE calling shortenPoints(animatedTotal); extract this into a local variable
before the return to simplify the markup: call const { number, suffix } =
shortenPoints(animatedTotal) above the JSX in the Points page/component (where
animatedTotal and tierInfo are available) and replace the IIFE block with
{number} {suffix && <span className="text-primary-1">{suffix}</span>}. This
keeps behavior identical while making the render cleaner and easier to read.

140-176: Consider extracting the tier threshold check to reduce duplication.

The tierInfo?.data.currentTier < 2 check appears twice (lines 161 and 170). Extracting to a variable improves readability:

♻️ Optional refactor
+                   const showNextTier = tierInfo?.data.currentTier < 2
                    {/* ... progress bar ... */}
-                   {tierInfo?.data.currentTier < 2 && (
+                   {showNextTier && (
                        <Image
                            src={getTierBadge(tierInfo?.data.currentTier + 1)}
                            // ...
                        />
                    )}
                </div>
-               {tierInfo?.data.currentTier < 2 && (
+               {showNextTier && (
                    <p className="text-center text-sm text-grey-1">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/points/page.tsx around lines 140 - 176, The repeated
check tierInfo?.data.currentTier < 2 should be extracted to a local boolean
(e.g., const isBelowMaxTier = tierInfo?.data?.currentTier < 2) and used in the
JSX for the conditional rendering around the progress bar badge (Image using
getTierBadge(...)) and the points-to-next-tier paragraph (formatPoints(...));
update references in the progress width calculation to use the extracted boolean
where appropriate to keep the logic clear and avoid duplicating the tier
threshold check.
scripts/validate-content.ts (2)

143-146: Inconsistent empty-string validation between content and entity frontmatter.

Content frontmatter validation (line 143) checks for empty strings:

fm[field] === undefined || fm[field] === null || fm[field] === ''

Entity frontmatter validation (line 193) does not:

entityFm[field] === undefined || entityFm[field] === null

If entity fields should also reject empty strings, align the checks. If intentional, a comment explaining the difference would help.

Also applies to: 192-196

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

In `@scripts/validate-content.ts` around lines 143 - 146, The entity frontmatter
validation currently checks only for undefined/null (entityFm[field] ===
undefined || entityFm[field] === null) while the content frontmatter check
includes empty-string rejection (fm[field] === ''), causing inconsistency;
either add the empty-string check to the entity validation by changing the
condition to include || entityFm[field] === '' (and mirror this for the similar
block at 192-196), or if empty strings are allowed intentionally add a
clarifying comment above the entity validation referencing entityFm and
explaining why '' is permitted.

45-62: Silent catch blocks may hide directory access issues.

Both listDirs and listMdFiles silently return empty arrays on any error. This could mask legitimate problems like permission errors or broken symlinks during validation runs.

Consider logging a warning when directory access fails unexpectedly.

♻️ Suggested improvement
 function listDirs(dir: string): string[] {
     try {
         return fs
             .readdirSync(dir, { withFileTypes: true })
             .filter((d) => d.isDirectory())
             .map((d) => d.name)
-    } catch {
+    } catch (e) {
+        warn(`Could not read directory: ${rel(dir)} — ${(e as Error).message}`)
         return []
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/validate-content.ts` around lines 45 - 62, The current silent catch
blocks in listDirs and listMdFiles hide filesystem errors; change both catch
clauses to capture the error (e.g., catch (err)) and emit a warning including
the directory path and error details (use processLogger.warn or console.warn)
before returning []; update listDirs and listMdFiles to log something like
"Failed to read directory <dir>:" plus the error/message so permission errors or
broken symlinks are visible during validation runs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@scripts/validate-content.ts`:
- Line 117: The contentPath is constructed with an extra 'content' segment
causing double-nesting; update the construction of contentPath (the variable
named contentPath that uses ROOT and config.contentDir) to join ROOT directly
with config.contentDir so the resulting path is ROOT/{contentDir} instead of
ROOT/content/{contentDir}; ensure you only reference ROOT and config.contentDir
in the path.join call.

In `@src/app/`(mobile-ui)/points/page.tsx:
- Line 211: Replace the incorrect user-facing string in the JSX that currently
reads "You earn rewards whenever friends your friends use Peanut!" with a
grammatically correct phrase; locate the JSX in
src/app/(mobile-ui)/points/page.tsx (the element rendering that text) and change
it to "You earn rewards whenever your friends use Peanut!" (or "You earn rewards
whenever friends of your friends use Peanut!" if the intention was second-degree
referrals).

In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx:
- Around line 16-25: The generateStaticParams function currently returns an
empty array in production which, together with dynamicParams = false, causes
404s; remove the early production guard (delete the if (process.env.NODE_ENV ===
'production') return [] line) so generateStaticParams always enumerates
SUPPORTED_LOCALES using getAllPosts(locale) with the existing fallback to 'en',
or alternatively change dynamicParams to true if on-demand rendering is
intended—update either generateStaticParams or dynamicParams in
blog/[slug]/page.tsx (and the other blog routes) to ensure production pages are
generated or can be rendered dynamically.

In `@src/components/Marketing/mdx/ExchangeWidget.tsx`:
- Around line 74-90: The ExchangeWidget currently uses Suspense but has no error
boundary, so runtime errors in ExchangeWidgetInner (or nested
ExchangeRateWidget) can crash the page; add an Error Boundary around the
Suspense/content inside the ExchangeWidget function: create or reuse an
ErrorBoundary component (e.g., ExchangeWidgetErrorBoundary or a generic
ErrorBoundary class component) and wrap <Suspense> / <ExchangeWidgetInner ... />
with it, providing a graceful fallback UI (matching or similar to the existing
Suspense fallback) and ensure the boundary captures errors from
ExchangeWidgetInner and ExchangeRateWidget to prevent a full-page crash.

---

Duplicate comments:
In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx:
- Line 55: After the isValidLocale check, TypeScript still treats locale as
string; update the call to getTranslations so the locale is cast to the Locale
type (e.g., pass locale as Locale) to match the getTranslations signature.
Locate the call to getTranslations in page.tsx (after the isValidLocale guard)
and change the argument to a casted Locale to eliminate the type error while
keeping the runtime guard intact.

---

Nitpick comments:
In `@scripts/validate-content.ts`:
- Around line 143-146: The entity frontmatter validation currently checks only
for undefined/null (entityFm[field] === undefined || entityFm[field] === null)
while the content frontmatter check includes empty-string rejection (fm[field]
=== ''), causing inconsistency; either add the empty-string check to the entity
validation by changing the condition to include || entityFm[field] === '' (and
mirror this for the similar block at 192-196), or if empty strings are allowed
intentionally add a clarifying comment above the entity validation referencing
entityFm and explaining why '' is permitted.
- Around line 45-62: The current silent catch blocks in listDirs and listMdFiles
hide filesystem errors; change both catch clauses to capture the error (e.g.,
catch (err)) and emit a warning including the directory path and error details
(use processLogger.warn or console.warn) before returning []; update listDirs
and listMdFiles to log something like "Failed to read directory <dir>:" plus the
error/message so permission errors or broken symlinks are visible during
validation runs.

In `@src/app/`(mobile-ui)/points/page.tsx:
- Around line 88-91: The useEffect calling fetchUser in page.tsx currently has
an empty dependency array and triggers exhaustive-deps warnings; update the
effect to either include fetchUser in the dependency array (useEffect(() => {
fetchUser() }, [fetchUser])) or, if fetchUser is stable and you intentionally
want to run it only once, add an inline ESLint disable comment above the effect
(// eslint-disable-next-line react-hooks/exhaustive-deps) with a brief note
explaining that fetchUser is stable and the effect should run once; reference
the useEffect that calls fetchUser to apply the chosen change.
- Around line 302-306: The InviteFriendsModal is being rendered with username ??
'' which allows an empty string to reach generateInviteCodeLink; instead, guard
rendering by only opening or mounting InviteFriendsModal when a valid username
exists—e.g., conditionally render InviteFriendsModal when isInviteModalOpen &&
username (or check inside the click handler before setIsInviteModalOpen(true));
ensure generateInviteCodeLink(username) is only called with a non-empty username
(references: InviteFriendsModal, generateInviteCodeLink, username,
isInviteModalOpen, setIsInviteModalOpen).
- Around line 118-128: The JSX contains an IIFE calling
shortenPoints(animatedTotal); extract this into a local variable before the
return to simplify the markup: call const { number, suffix } =
shortenPoints(animatedTotal) above the JSX in the Points page/component (where
animatedTotal and tierInfo are available) and replace the IIFE block with
{number} {suffix && <span className="text-primary-1">{suffix}</span>}. This
keeps behavior identical while making the render cleaner and easier to read.
- Around line 140-176: The repeated check tierInfo?.data.currentTier < 2 should
be extracted to a local boolean (e.g., const isBelowMaxTier =
tierInfo?.data?.currentTier < 2) and used in the JSX for the conditional
rendering around the progress bar badge (Image using getTierBadge(...)) and the
points-to-next-tier paragraph (formatPoints(...)); update references in the
progress width calculation to use the extracted boolean where appropriate to
keep the logic clear and avoid duplicating the tier threshold check.

In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx:
- Around line 106-124: Extract the repeated breadcrumb JSX into a reusable
component (e.g., BreadcrumbNav) and replace the inline rendering in page.tsx and
ContentPage.tsx with <BreadcrumbNav breadcrumbs={breadcrumbs} />; move the
nav/ol/li structure and Link handling into the new BreadcrumbNav component,
accept a breadcrumbs prop (array with href/name), preserve the aria-label,
classes, truncation, and key usage (key={crumb.href}), and export/import the
component in both files to remove duplication.
- Line 66: Replace the hardcoded domain with the shared BASE_URL constant:
import BASE_URL at the top of the file and use it to build the URLs where you
currently have `https://peanut.me` (specifically the properties that set
mainEntityOfPage and the other URL at the second occurrence around line 96 in
page.tsx) so the expressions become template strings based on BASE_URL (e.g.,
`${BASE_URL}/${locale}/blog/${slug}`) instead of the hardcoded domain.

In `@src/components/Marketing/ContentPage.tsx`:
- Around line 20-29: This component duplicates breadcrumb construction; replace
the local const breadcrumbSchema with the shared breadcrumbSchema(items) helper
from src/lib/seo/schemas.tsx by normalizing the items you pass (convert each
{name, href} to the helper's expected shape, e.g., {name, url}) or update the
shared breadcrumbSchema helper to accept href and preserve the existing
absolute-URL behavior (detect strings starting with "http" and avoid prefixing
BASE_URL) so the component can import and reuse breadcrumbSchema(items) instead
of reimplementing it.

In `@src/components/Marketing/mdx/ExchangeWidget.tsx`:
- Around line 58-60: Update the ctaAction passed to ExchangeWidget so query
values are URL-encoded before navigation: instead of interpolating from and to
directly in router.push, encode them (e.g., via encodeURIComponent or building a
URLSearchParams) to produce a safe query string; locate the ctaAction handler in
ExchangeWidget.tsx where router.push(`/send?from=${from}&to=${to}`) is used and
replace the interpolation with an encoded query string using the chosen encoding
method.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c9561fa and 2c054ff.

📒 Files selected for processing (21)
  • scripts/validate-content.ts
  • src/app/(mobile-ui)/points/page.tsx
  • src/app/[locale]/(marketing)/blog/[slug]/page.tsx
  • src/app/[locale]/(marketing)/error.tsx
  • src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
  • src/components/Marketing/ContentPage.tsx
  • src/components/Marketing/DestinationGrid.tsx
  • src/components/Marketing/mdx/CTA.tsx
  • src/components/Marketing/mdx/Callout.tsx
  • src/components/Marketing/mdx/ExchangeWidget.tsx
  • src/components/Marketing/mdx/FAQ.tsx
  • src/components/Marketing/mdx/ProseStars.tsx
  • src/components/Marketing/mdx/Stars.tsx
  • src/components/Marketing/mdx/Steps.tsx
  • src/components/Marketing/mdx/components.tsx
  • src/components/Marketing/mdx/constants.ts
  • src/components/Points/CashCard.tsx
  • src/components/Setup/Views/SignTestTransaction.tsx
  • src/data/seo/index.ts
  • src/data/seo/payment-methods.ts
  • src/data/seo/utils.ts
🚧 Files skipped from review as they are similar to previous changes (5)
  • src/app/[locale]/(marketing)/send-money-to/[country]/page.tsx
  • src/components/Marketing/DestinationGrid.tsx
  • src/components/Marketing/mdx/Callout.tsx
  • src/app/[locale]/(marketing)/error.tsx
  • src/components/Marketing/mdx/CTA.tsx

}

function validateContentType(config: ContentTypeConfig): TypeCounts {
const contentPath = path.join(ROOT, 'content', config.contentDir)
Copy link
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
# Verify actual directory structure under src/content
echo "=== Top-level directories under src/content ==="
ls -la src/content/ 2>/dev/null || echo "src/content not found"

echo ""
echo "=== Looking for countries directory ==="
fd -t d "countries" --max-depth 5 src/

echo ""
echo "=== Check if src/content/content exists ==="
test -d src/content/content && echo "src/content/content EXISTS" || echo "src/content/content DOES NOT EXIST"

echo ""
echo "=== Full tree of src/content (if exists) ==="
tree -L 3 src/content 2>/dev/null || find src/content -maxdepth 3 -type d 2>/dev/null | head -20

Repository: peanutprotocol/peanut-ui

Length of output: 429


🏁 Script executed:

#!/bin/bash
# Check the ROOT definition and context around the flagged lines
echo "=== Checking ROOT definition and context (lines 1-30) ==="
head -30 scripts/validate-content.ts

echo ""
echo "=== Examining the contentPath usage (lines 110-130) ==="
sed -n '110,130p' scripts/validate-content.ts

echo ""
echo "=== Check the overall file structure ==="
wc -l scripts/validate-content.ts

Repository: peanutprotocol/peanut-ui

Length of output: 1792


🏁 Script executed:

#!/bin/bash
# Find ContentTypeConfig definition and see what contentDir values contain
echo "=== Looking for ContentTypeConfig definition ==="
grep -n "ContentTypeConfig\|contentDir:" scripts/validate-content.ts | head -20

echo ""
echo "=== Check all path.join calls in the file ==="
grep -n "path.join" scripts/validate-content.ts

echo ""
echo "=== Look at the config object usage (search for where configs are defined) ==="
grep -n "contentDir" scripts/validate-content.ts

Repository: peanutprotocol/peanut-ui

Length of output: 2384


🏁 Script executed:

#!/bin/bash
# Check if src/content is gitignored and examine the actual expected structure
echo "=== Check .gitignore for src/content pattern ==="
grep -r "src/content" .gitignore 2>/dev/null || echo "No src/content patterns in .gitignore"

echo ""
echo "=== Check if there are any references to the actual directory structure in docs/comments ==="
grep -r "src/content/content\|src/content/input" . --include="*.ts" --include="*.md" --include="*.js" | head -10

echo ""
echo "=== Look at line 227 more carefully (convert/pairs path) ==="
sed -n '225,235p' scripts/validate-content.ts

Repository: peanutprotocol/peanut-ui

Length of output: 682


🏁 Script executed:

#!/bin/bash
# Examine the pattern more carefully - check if this is actually intentional
echo "=== Examine the full validateContentType function context ==="
sed -n '64,90p' scripts/validate-content.ts

echo ""
echo "=== Check interface definition for contentDir and entityDir ==="
sed -n '66,75p' scripts/validate-content.ts

echo ""
echo "=== Look at entityDir values ==="
grep -n "entityDir" scripts/validate-content.ts | head -15

echo ""
echo "=== Check if there's a README or docs explaining structure ==="
find . -maxdepth 2 -name "README*" -o -name "CONTENT*" | head -5

Repository: peanutprotocol/peanut-ui

Length of output: 2322


🏁 Script executed:

#!/bin/bash
# Let's check if this validation script is actually used and recent
echo "=== Check git history for this file ==="
git log --oneline scripts/validate-content.ts | head -5

echo ""
echo "=== Check if script is referenced in package.json ==="
grep -A 5 -B 5 "validate-content" package.json 2>/dev/null || echo "No reference in package.json"

echo ""
echo "=== Verify if line 227 path makes sense ==="
echo "Line 227 constructs: path.join(ROOT, 'content/convert/pairs.yaml')"
echo "That would be: src/content/content/convert/pairs.yaml"
echo ""
echo "Does src/content/content exist? NO (verified earlier)"
echo "Does src/content/convert exist?"
test -d src/content/convert && echo "YES" || echo "NO"

echo ""
echo "=== Review the interface comment again - is it saying ROOT is the project root? ==="
sed -n '66,75p' scripts/validate-content.ts

Repository: peanutprotocol/peanut-ui

Length of output: 1428


🏁 Script executed:

#!/bin/bash
# Let's trace through the actual paths being constructed
echo "=== Path construction analysis ==="
echo "ROOT = path.join(process.cwd(), 'src/content')"
echo "       = {cwd}/src/content"
echo ""
echo "Line 117: path.join(ROOT, 'content', config.contentDir)"
echo "         = {cwd}/src/content/content/{contentDir}"
echo "         Example: {cwd}/src/content/content/countries"
echo ""
echo "Line 184: path.join(ROOT, 'input/data', config.entityDir, \`\${slug}.md\`)"
echo "         = {cwd}/src/content/input/data/{entityDir}/{slug}.md"
echo "         Example: {cwd}/src/content/input/data/countries/us.md"
echo ""
echo "Line 227: path.join(ROOT, 'content/convert/pairs.yaml')"
echo "         = {cwd}/src/content/content/convert/pairs.yaml"
echo ""

echo "=== Check the interface comments vs actual construction ==="
echo "Interface says contentDir is 'Directory under content/'"
echo "Interface says entityDir is 'Directory under input/data/'"
echo ""
echo "If comments were accurate:"
echo "  - contentDir should construct to: {root}/content/{contentDir}"
echo "  - entityDir should construct to: {root}/input/data/{entityDir}"
echo ""
echo "But ROOT = {root}/src/content, so we get:"
echo "  - contentDir constructs to: {root}/src/content/content/{contentDir}  ❌ DOUBLE NESTING"
echo "  - entityDir constructs to: {root}/src/content/input/data/{entityDir} ❌ WRONG NESTING"
echo ""
echo "=== Conclusion ==="
echo "The 'content' in line 117 appears to be erroneous and causes double-nesting"

Repository: peanutprotocol/peanut-ui

Length of output: 1294


Remove double-nesting in content path construction.

The path at line 117 has redundant nesting. ROOT is already src/content (line 18), so path.join(ROOT, 'content', config.contentDir) produces src/content/content/{contentDir}. The interface comment correctly notes that contentDir should be "Directory under content/" — meaning the path should be src/content/{contentDir}, not src/content/content/{contentDir}.

Fix by removing the extra 'content' segment:

Diff
-    const contentPath = path.join(ROOT, 'content', config.contentDir)
+    const contentPath = path.join(ROOT, config.contentDir)
📝 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
const contentPath = path.join(ROOT, 'content', config.contentDir)
const contentPath = path.join(ROOT, config.contentDir)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/validate-content.ts` at line 117, The contentPath is constructed with
an extra 'content' segment causing double-nesting; update the construction of
contentPath (the variable named contentPath that uses ROOT and
config.contentDir) to join ROOT directly with config.contentDir so the resulting
path is ROOT/{contentDir} instead of ROOT/content/{contentDir}; ensure you only
reference ROOT and config.contentDir in the path.join call.

invited you.{' '}
</>
)}
<br></br>You earn rewards whenever friends your friends use Peanut!
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Typo in user-facing text: "friends your friends".

The text "You earn rewards whenever friends your friends use Peanut!" has a grammatical error.

🐛 Proposed fix
-                           <br></br>You earn rewards whenever friends your friends use Peanut!
+                           <br />You earn rewards whenever your friends use Peanut!
📝 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
<br></br>You earn rewards whenever friends your friends use Peanut!
<br />You earn rewards whenever your friends use Peanut!
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(mobile-ui)/points/page.tsx at line 211, Replace the incorrect
user-facing string in the JSX that currently reads "You earn rewards whenever
friends your friends use Peanut!" with a grammatically correct phrase; locate
the JSX in src/app/(mobile-ui)/points/page.tsx (the element rendering that text)
and change it to "You earn rewards whenever your friends use Peanut!" (or "You
earn rewards whenever friends of your friends use Peanut!" if the intention was
second-degree referrals).

Comment on lines +16 to +25
export async function generateStaticParams() {
if (process.env.NODE_ENV === 'production') return []
// Generate params for locales that have blog content (fall back to en slugs)
return SUPPORTED_LOCALES.flatMap((locale) => {
let posts = getAllPosts(locale as Locale)
if (posts.length === 0) posts = getAllPosts('en')
return posts.map((post) => ({ locale, slug: post.slug }))
})
}
export const dynamicParams = false
Copy link
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 all pages with generateStaticParams and dynamicParams
rg -n "generateStaticParams|dynamicParams" --type=ts -g "src/app/**/page.tsx" -A 3 -B 1

Repository: peanutprotocol/peanut-ui

Length of output: 9138


🏁 Script executed:

#!/bin/bash
# Check the blog/page.tsx file mentioned in the review
fd "blog/page.tsx" src/ -x cat -n {}

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Check for revalidate patterns in the blog routes
rg -n "revalidate" --type=ts src/app/\[locale\]/\(marketing\)/blog/

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Look at the layout file to see if it defines static params
fd "layout.tsx" src/app/\[locale\]/\(marketing\)/blog/ -x cat -n {}

Repository: peanutprotocol/peanut-ui

Length of output: 50


🏁 Script executed:

#!/bin/bash
# Check if there's ISR/incremental static regeneration configuration
fd "next.config" . -x cat -n {}

Repository: peanutprotocol/peanut-ui

Length of output: 9659


This combination prevents blog pages from rendering in production.

generateStaticParams() returns an empty array in production while dynamicParams is set to false. With no static params generated and dynamic rendering disabled, all blog URLs will return 404 in production.

This pattern is unique to the blog routes (blog/[slug]/page.tsx, blog/page.tsx, blog/category/[cat]/page.tsx). All other marketing pages return actual parameters from their data sources (countries, exchanges, methods, etc.). No revalidate or ISR configuration is present in next.config.js, so there's no fallback mechanism.

Confirm whether this is intentional (e.g., blog intentionally disabled in production) or a bug that needs fixing.

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

In `@src/app/`[locale]/(marketing)/blog/[slug]/page.tsx around lines 16 - 25, The
generateStaticParams function currently returns an empty array in production
which, together with dynamicParams = false, causes 404s; remove the early
production guard (delete the if (process.env.NODE_ENV === 'production') return
[] line) so generateStaticParams always enumerates SUPPORTED_LOCALES using
getAllPosts(locale) with the existing fallback to 'en', or alternatively change
dynamicParams to true if on-demand rendering is intended—update either
generateStaticParams or dynamicParams in blog/[slug]/page.tsx (and the other
blog routes) to ensure production pages are generated or can be rendered
dynamically.

Comment on lines +74 to +90
export function ExchangeWidget({ destinationCurrency, sourceCurrency }: ExchangeWidgetProps) {
return (
<Suspense
fallback={
<section
className="relative my-8 w-full overflow-hidden pb-14 pt-10 md:pb-18 md:pt-14"
style={{ backgroundColor: '#90A8ED' }}
>
<div className="mx-auto flex max-w-[640px] justify-center px-6 md:px-4">
<div className="btn btn-shadow-primary-4 h-[300px] w-full animate-pulse bg-white md:w-[420px]" />
</div>
</section>
}
>
<ExchangeWidgetInner destinationCurrency={destinationCurrency} sourceCurrency={sourceCurrency} />
</Suspense>
)
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add an error boundary to prevent page crashes.

Per the PR review discussion, MDX component runtime errors can crash entire pages. This widget uses Suspense for loading states but lacks error handling. If ExchangeWidgetInner or ExchangeRateWidget throws, the error will propagate and potentially cause a white-screen failure.

Wrap the content in an error boundary with a graceful fallback.

🛡️ Suggested approach
+import { ErrorBoundary } from '@/components/Global/ErrorBoundary' // or your existing error boundary
+
 export function ExchangeWidget({ destinationCurrency, sourceCurrency }: ExchangeWidgetProps) {
     return (
-        <Suspense
-            fallback={
-                <section
-                    className="relative my-8 w-full overflow-hidden pb-14 pt-10 md:pb-18 md:pt-14"
-                    style={{ backgroundColor: '#90A8ED' }}
-                >
-                    <div className="mx-auto flex max-w-[640px] justify-center px-6 md:px-4">
-                        <div className="btn btn-shadow-primary-4 h-[300px] w-full animate-pulse bg-white md:w-[420px]" />
+        <ErrorBoundary fallback={<div className="my-8 text-center text-gray-500">Exchange widget unavailable</div>}>
+            <Suspense
+                fallback={
+                    <section
+                        className="relative my-8 w-full overflow-hidden pb-14 pt-10 md:pb-18 md:pt-14"
+                        style={{ backgroundColor: '#90A8ED' }}
+                    >
+                        <div className="mx-auto flex max-w-[640px] justify-center px-6 md:px-4">
+                            <div className="btn btn-shadow-primary-4 h-[300px] w-full animate-pulse bg-white md:w-[420px]" />
+                        </div>
+                    </section>
+                }
+            >
+                <ExchangeWidgetInner destinationCurrency={destinationCurrency} sourceCurrency={sourceCurrency} />
+            </Suspense>
+        </ErrorBoundary>
-                    </div>
-                </section>
-            }
-        >
-            <ExchangeWidgetInner destinationCurrency={destinationCurrency} sourceCurrency={sourceCurrency} />
-        </Suspense>
     )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Marketing/mdx/ExchangeWidget.tsx` around lines 74 - 90, The
ExchangeWidget currently uses Suspense but has no error boundary, so runtime
errors in ExchangeWidgetInner (or nested ExchangeRateWidget) can crash the page;
add an Error Boundary around the Suspense/content inside the ExchangeWidget
function: create or reuse an ErrorBoundary component (e.g.,
ExchangeWidgetErrorBoundary or a generic ErrorBoundary class component) and wrap
<Suspense> / <ExchangeWidgetInner ... /> with it, providing a graceful fallback
UI (matching or similar to the existing Suspense fallback) and ensure the
boundary captures errors from ExchangeWidgetInner and ExchangeRateWidget to
prevent a full-page crash.

Race condition: after clicking "Back to home", resetting withdraw state
(amountToWithdraw = '') triggered useEffect guards that called
router.replace('/withdraw'), overriding the router.push('/home').

- Crypto: guard early redirect with currentView !== 'STATUS'
- Bank: guard useEffect with view === 'SUCCESS', reorder NavHeader
  onPrev to navigate before resetting state, add onComplete prop
- Exclude /api/peanut/user/* from SW cache (NetworkOnly) — stale 401
  responses were being served from cache, causing infinite spinner loops
  that only worked in incognito (no SW)
- Stop refreshing JWT cookie maxAge on every read in getJWTCookie() —
  this was keeping expired JWTs alive for 30 more days on each page load
- Add maxAge: 30 days consistently to login, register, and get-jwt-token
  routes so the cookie is persistent from the start without needing
  refresh-on-read
Move the cookie maxAge refresh from getJWTCookie() (every read) to
get-user-from-cookie route (only on 200). Active users stay logged in
indefinitely; expired JWTs naturally expire without being refreshed.
- Fix user-facing typo on points page: "friends your friends" → "your friends"
- Add TODO comment on blog/[slug] page: when blog content is added,
  the generateStaticParams production guard + dynamicParams=false would
  404 all blog pages. No effect currently (0 blog posts exist).
@Hugo0 Hugo0 merged commit f9e26b0 into peanut-wallet Feb 26, 2026
8 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.

3 participants