Prod Release: SEO + V3 Transitivity + GBP + Card Pioneers (flagged)#1691
Prod Release: SEO + V3 Transitivity + GBP + Card Pioneers (flagged)#1691Hugo0 merged 97 commits intopeanut-walletfrom
Conversation
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
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
There was a problem hiding this comment.
Actionable comments posted: 7
🧹 Nitpick comments (14)
src/app/[locale]/(marketing)/blog/category/[cat]/page.tsx (1)
59-59:getAllCategoriesis 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:dangerouslySetInnerHTMLwith 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-htmlorisomorphic-dompurify) ingetPostBySlugwould 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 insrc/seo/schemas.tsx— consider reusing them.
faqSchema()andbreadcrumbSchema()insrc/seo/schemas.tsxproduce 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 usingnext/imageinstead 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/imagewould 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 redundantcanonicalparameter frommetadataHelpercall.
metadataHelperalready setsalternates.canonicalwhen acanonicalparameter is provided (seemetadata.tsline 41). Passing it here and then redefiningalternatescreates unnecessary duplication. Since you're settingalternates.canonicalat the page level anyway, remove thecanonicalparameter from themetadataHelpercall: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: ExtractshortenPointsresult 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
returnstatement 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 aneslint-disablecomment for the intentionally empty dependency array.
fetchUseris omitted from theuseEffectdeps 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: Redundantinvites &&guard —invites?.inviteesalready handles the null case.
invites?.inviteesshort-circuits ifinvitesis nullish, so the leadinginvites &&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: TypeinviteesRefexplicitly — same React 19 / TypeScript concern as ininvites/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: TypelistRefexplicitly to avoid a React 19 / TypeScript incompatibility.
useRef(null)without a type argument infersRefObject<null>, which is incompatible with theHTMLDivElementref expected by thedivat line 85 and byframer-motion'suseInView. In React 19,useRef<T>(null)returnsRefObject<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:seois 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 onreadPageContentLocalizedweakens type safety forpublishedguard.Line 29 in
generateMetadatacorrectly annotates the call with<{ title: string; description: string; published?: boolean }>, but the page component's call on line 49 is untyped. Without it,frontmatterisRecord<string, unknown>, somdxSource.frontmatter.publishedisunknown. The=== falsecomparison 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, butmetadataBaseis configured and resolution works correctly.The original concern about
metadataBasebeing absent is incorrect. It's configured both in the root layout (src/app/layout.tsx:17) and withinmetadataHelperitself (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 inalternates.canonical) and then immediately overridden by explicitly settingalternatesagain 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
languagesobject fromgetAlternates, but cleaner approaches exist:
- Don't pass canonical to
metadataHelper, only set it in the page's explicitalternatesblock, or- Modify
metadataHelperto 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
📒 Files selected for processing (17)
src/app/(mobile-ui)/home/page.tsxsrc/app/(mobile-ui)/points/invites/page.tsxsrc/app/(mobile-ui)/points/page.tsxsrc/app/[locale]/(marketing)/blog/[slug]/page.tsxsrc/app/[locale]/(marketing)/blog/category/[cat]/page.tsxsrc/app/[locale]/(marketing)/blog/page.tsxsrc/app/[locale]/(marketing)/send-money-from/[from]/to/[to]/page.tsxsrc/app/[locale]/(marketing)/send-money-to/[country]/page.tsxsrc/app/[locale]/(marketing)/team/page.tsxsrc/app/actions/card.tssrc/app/sitemap.tssrc/components/Card/CardGeoScreen.tsxsrc/components/LandingPage/LandingPageClient.tsxsrc/components/LandingPage/LandingPageShell.tsxsrc/components/Marketing/mdx/RelatedPages.tsxsrc/contentsrc/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) |
There was a problem hiding this comment.
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.
| 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.
| {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> | ||
| ))} |
There was a problem hiding this comment.
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.
| {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) |
There was a problem hiding this comment.
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.
| 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.
| return pages.map((page) => ({ | ||
| url: `${BASE_URL}${page.path}`, | ||
| lastModified: page.lastModified ?? BUILD_DATE, |
There was a problem hiding this comment.
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.
| 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.
| faqData: { | ||
| heading: string | ||
| questions: FAQQuestion[] | ||
| marquee: { visible: boolean; message: string } | ||
| } |
There was a problem hiding this comment.
🧩 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/ -A5Repository: 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 -A2Repository: 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 -A2Repository: 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 -A10Repository: 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 -A5Repository: 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.
| 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.
| const handleTouchStart = (event: TouchEvent) => { | ||
| touchStartY.current = event.touches[0].clientY | ||
| } |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Actionable comments posted: 4
♻️ Duplicate comments (1)
src/app/[locale]/(marketing)/blog/[slug]/page.tsx (1)
55-55:⚠️ Potential issue | 🟡 MinorMissing
as Localecast onlocalepassed togetTranslations.After the
isValidLocaleguard on Line 50,localeis logically aLocale, but TypeScript still types it asstring. ThegetTranslationsfunction signature expectsLocale(seesrc/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
encodeURIComponentorURLSearchParamswould 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 existingbreadcrumbSchemahelper fromsrc/lib/seo/schemas.tsx.The relevant code snippets show a
breadcrumbSchema(items)helper already exists insrc/lib/seo/schemas.tsx. This component duplicates that logic with a slightly different interface (hrefvsurl). 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 withhttp. 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: UseBASE_URLconstant instead of hardcodedhttps://peanut.me.Lines 66 and 96 hardcode the domain, while
ContentPage.tsxuses theBASE_URLconstant 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 addingfetchUserto the dependency array or using an eslint-disable comment.The empty dependency array triggers a lint warning (exhaustive-deps) since
fetchUseris called but not listed. While this "run once on mount" pattern is intentional andfetchUseris 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 ifusernameis undefined. The modal uses this to generate invite codes viagenerateInviteCodeLink(username). If the user is authenticated (which they must be to reach this page),usernameshould 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 < 2check 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] === nullIf 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
listDirsandlistMdFilessilently 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
📒 Files selected for processing (21)
scripts/validate-content.tssrc/app/(mobile-ui)/points/page.tsxsrc/app/[locale]/(marketing)/blog/[slug]/page.tsxsrc/app/[locale]/(marketing)/error.tsxsrc/app/[locale]/(marketing)/send-money-to/[country]/page.tsxsrc/components/Marketing/ContentPage.tsxsrc/components/Marketing/DestinationGrid.tsxsrc/components/Marketing/mdx/CTA.tsxsrc/components/Marketing/mdx/Callout.tsxsrc/components/Marketing/mdx/ExchangeWidget.tsxsrc/components/Marketing/mdx/FAQ.tsxsrc/components/Marketing/mdx/ProseStars.tsxsrc/components/Marketing/mdx/Stars.tsxsrc/components/Marketing/mdx/Steps.tsxsrc/components/Marketing/mdx/components.tsxsrc/components/Marketing/mdx/constants.tssrc/components/Points/CashCard.tsxsrc/components/Setup/Views/SignTestTransaction.tsxsrc/data/seo/index.tssrc/data/seo/payment-methods.tssrc/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) |
There was a problem hiding this comment.
🧩 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 -20Repository: 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.tsRepository: 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.tsRepository: 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.tsRepository: 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 -5Repository: 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.tsRepository: 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.
| 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.
src/app/(mobile-ui)/points/page.tsx
Outdated
| invited you.{' '} | ||
| </> | ||
| )} | ||
| <br></br>You earn rewards whenever friends your friends use Peanut! |
There was a problem hiding this comment.
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.
| <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).
| 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 |
There was a problem hiding this comment.
🧩 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 1Repository: 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.
| 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> | ||
| ) |
There was a problem hiding this comment.
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).
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
peanut-contentsubmodule.useInView, sharedInviteePointsBadgecomponent.formatPoints()(thousands separators) +shortenPoints()(K/M suffix with styled pink span) utilities.CashCardnow showshasCashbackLeft: booleanstatus + lifetime earned, instead of dollar allowance.CARD_PIONEERS_ENABLED = false).hotfix/loading-screen(fix: kernel client stuck loading screen + retry logic #1685) from prod.Related PRs
Why a release branch instead of staging → prod?
Kushagra's KYC 2.0 rehaul (#1679, #1683, #1689, #1690) landed on
peanut-wallet-devand 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
src/hooks/useCountUp.ts— Animation hook with localStorage memory,isAnimatingref to prevent render loops. Had a flickering bug that was fixed.src/components/Points/CashCard.tsx— Binary cashback display. Verify UX makes sense.src/app/(mobile-ui)/points/page.tsx— Hero count-up, scroll-triggered invitees, CashCard integration.src/components/Marketing/— MDX rendering pipeline, ensure no SSR issues.src/app/(mobile-ui)/card/— Should be completely hidden behind feature flag.src/content/— Currently onfeat/mdx-componentsbranch (will be pointed tomainbefore merge).Test Plan
/points— hero count-up animation, scroll-triggered invitee badges/points/invites— summary animation, list animations on scroll/en/send-money-to/argentina)pnpm buildpasses