Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f567913
fix: prevent duplicate Crisp conversations with session continuity to…
chip-peanut-bot[bot] Mar 12, 2026
57af5ee
fix: set CRISP_TOKEN_ID in inline script to prevent race condition
Hugo0 Mar 12, 2026
866876d
fix: show dollar amounts instead of misleading percentages in cashbac…
chip-peanut-bot[bot] Mar 12, 2026
73e61f5
fix: remove misleading percentage from perk messaging, show dollar am…
Hugo0 Mar 12, 2026
fcc7810
fix: clear token on iframe reset, fix implicit globals in Crisp script
Hugo0 Mar 12, 2026
a0dfbc5
fix: tier cashback messaging tone by amount
Hugo0 Mar 12, 2026
4268f96
fix: enable session_merge for smooth migration of existing users
chip-peanut-bot[bot] Mar 12, 2026
ffdc9ad
fix: move CRISP_RUNTIME_CONFIG into inline script to prevent race con…
Hugo0 Mar 12, 2026
b4f75cf
feat: add PostHog person link to Crisp support metadata
chip-peanut-bot[bot] Mar 13, 2026
5c75d29
Merge pull request #1752 from peanutprotocol/dev
jjramirezn Mar 17, 2026
f985f2e
Update content submodule to latest main (35 commits)
chip-peanut-bot[bot] Mar 17, 2026
ef1d07c
Merge pull request #1759 from peanutprotocol/auto/update-content-2026…
Hugo0 Mar 18, 2026
9fc02d0
fix: auto-claim small perks and map sponsoredUsd field
Hugo0 Mar 18, 2026
e918501
chore: remove dead pre-claim branch for <$0.50 perks
Hugo0 Mar 18, 2026
637b0c6
fix: use typeof guard for sponsoredUsd mapping and auto-claim check
Hugo0 Mar 18, 2026
e476091
Merge pull request #1742 from peanutprotocol/fix/cashback-dollar-amounts
Hugo0 Mar 18, 2026
327570c
feat: add Bridge dashboard link to Crisp session data
Hugo0 Mar 18, 2026
f7f1abf
chore: add TODOs for iframe perf and Manteca dashboard link
Hugo0 Mar 18, 2026
98d32fb
Merge pull request #1740 from peanutprotocol/chip/fix-crisp-duplicate…
Hugo0 Mar 18, 2026
990d300
feat: add Chainstack as primary RPC for Polygon, Base, and BSC
Hugo0 Mar 19, 2026
6396d06
Merge pull request #1769 from peanutprotocol/feat/add-chainstack-rpcs-v2
Hugo0 Mar 19, 2026
5f9224f
fix: improve health checks — zerodev, rpc critical chain logic, webho…
chip-peanut-bot[bot] Mar 19, 2026
e30efbe
style: fix prettier formatting on backend health check
chip-peanut-bot[bot] Mar 19, 2026
815d005
fix: add 5s timeout to ZeroDev health check fetches
Hugo0 Mar 19, 2026
3ad8ee3
Merge pull request #1770 from peanutprotocol/fix/health-checks-cherry…
Hugo0 Mar 19, 2026
f1f0db6
feat: add /presskit and /press-kit redirect to Notion press kit
chip-peanut-bot[bot] Mar 20, 2026
9d42dae
Merge pull request #1774 from peanutprotocol/chip/presskit-redirect
kushagrasarathe Mar 20, 2026
f8c3190
fix: add app routes to link validator allowlist
chip-peanut-bot[bot] Mar 20, 2026
d4ec4d7
fix: add stories routes to link validator
chip-peanut-bot[bot] Mar 20, 2026
18d9719
Merge pull request #1780 from peanutprotocol/chip/validate-stories
Hugo0 Mar 23, 2026
600523b
Merge pull request #1777 from peanutprotocol/chip/hotfix-validate-lin…
Hugo0 Mar 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion redirects.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@
},
{
"source": "/:path*",
"has": [{ "type": "host", "value": "docs.peanut.me" }],
"has": [
{
"type": "host",
"value": "docs.peanut.me"
}
],
"destination": "https://peanut.me/en/help",
"permanent": true
},
Expand Down Expand Up @@ -74,5 +79,15 @@
"source": "/seedlings",
"destination": "https://peanutprotocol.notion.site/Influencer-information-form-2af8381175798067b271cd95a04d922a",
"permanent": false
},
{
"source": "/presskit",
"destination": "https://peanutprotocol.notion.site/Press-Kit-12f83811757981fc9ca5de581b20f50d",
"permanent": true
},
{
"source": "/press-kit",
"destination": "https://peanutprotocol.notion.site/Press-Kit-12f83811757981fc9ca5de581b20f50d",
"permanent": true
}
]
27 changes: 27 additions & 0 deletions scripts/validate-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,31 @@ function buildValidPaths(): Set<string> {
paths.add(p)
}

// App routes (behind auth / mobile-ui) — content may link to these
for (const p of [
'/profile',
'/profile/backup',
'/profile/edit',
'/profile/exchange-rate',
'/profile/identity-verification',
'/home',
'/send',
'/request',
'/settings',
'/history',
'/points',
'/recover-funds',
]) {
paths.add(p)
}

const countrySlugs = listDirs(path.join(CONTENT_DIR, 'countries'))
const competitorSlugs = listDirs(path.join(CONTENT_DIR, 'compare'))
const payWithSlugs = listDirs(path.join(CONTENT_DIR, 'pay-with'))
const depositSlugs = listDirs(path.join(CONTENT_DIR, 'deposit'))
const helpSlugs = listDirs(path.join(CONTENT_DIR, 'help'))
const useCaseSlugs = listDirs(path.join(CONTENT_DIR, 'use-cases'))
const storySlugs = listDirs(path.join(CONTENT_DIR, 'stories')).filter((s) => s !== 'index')
const withdrawSlugs = listDirs(path.join(CONTENT_DIR, 'withdraw'))
const exchangeSlugs = listEntitySlugs('exchanges')

Expand Down Expand Up @@ -117,6 +136,14 @@ function buildValidPaths(): Set<string> {
paths.add(`/${locale}/use-cases/${slug}`)
}

// Stories: /{locale}/stories and /{locale}/stories/{slug}
if (storySlugs.length > 0) {
paths.add(`/${locale}/stories`)
for (const slug of storySlugs) {
paths.add(`/${locale}/stories/${slug}`)
}
}

// Withdraw: /{locale}/withdraw/{slug}
for (const slug of withdrawSlugs) {
paths.add(`/${locale}/withdraw/to-${slug}`)
Expand Down
68 changes: 40 additions & 28 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,24 @@ export default function QRPayPage() {
clearTimeout(payingStateTimerRef.current)
payingStateTimerRef.current = null
}
// Map backend field name (sponsoredUsd) to frontend field name (amountSponsored)
const perkResponse = qrPayment.perk as Record<string, unknown> | undefined
if (qrPayment.perk && typeof perkResponse?.sponsoredUsd === 'number') {
qrPayment.perk.amountSponsored = perkResponse.sponsoredUsd
}

setQrPayment(qrPayment)

// Auto-claim small perks (<$0.50) — skip the hold-to-claim ceremony.
// The backend already claimed the perk at payment time, so this is just a UI shortcut.
if (
qrPayment.perk?.eligible &&
typeof qrPayment.perk.amountSponsored === 'number' &&
qrPayment.perk.amountSponsored < 0.5
) {
setPerkClaimed(true)
}

setIsSuccess(true)
} catch (error) {
// clear the timer on error to prevent race condition
Expand Down Expand Up @@ -1234,31 +1251,21 @@ export default function QRPayPage() {
<h2 className="text-lg font-bold">Eligible for a Peanut Perk!</h2>
<p className="text-sm text-gray-600">
{(() => {
const percentage = qrPayment?.perk?.discountPercentage || 100
const amountSponsored = qrPayment?.perk?.amountSponsored
const transactionUsd =
parseFloat(qrPayment?.details?.paymentAgainstAmount || '0') || 0

// Check if percentage matches the actual math (within 1% tolerance)
let percentageMatches = false
if (amountSponsored && transactionUsd > 0) {
const actualPercentage = (amountSponsored / transactionUsd) * 100
percentageMatches = Math.abs(actualPercentage - percentage) < 1
}

if (percentageMatches) {
if (percentage === 100) {
return 'This bill can be covered by Peanut. Claim it now to unlock your reward.'
} else if (percentage > 100) {
return `You're getting ${percentage}% back — that's more than you paid! Claim it now.`
} else {
return `You're getting ${percentage}% cashback! Claim it now to unlock your reward.`
// Always show actual dollar amount — never percentage (misleading due to dynamic caps)
// Note: perks <$0.50 are auto-claimed and skip this banner entirely
if (amountSponsored && typeof amountSponsored === 'number') {
if (transactionUsd > 0 && amountSponsored >= transactionUsd) {
return `This bill can be covered by Peanut! $${amountSponsored.toFixed(2)} back. Claim it now.`
}
return `Peanut's got you! $${amountSponsored.toFixed(2)} back on this payment. Claim it now.`
}

return amountSponsored && typeof amountSponsored === 'number'
? `Get $${amountSponsored.toFixed(2)} back!`
: 'Claim it now to unlock your reward.'
// Fallback: no amount available yet
return 'You earned a Peanut Perk! Claim it now to unlock your reward.'
})()}
</p>
</div>
Expand All @@ -1278,17 +1285,22 @@ export default function QRPayPage() {
const amountSponsored = qrPayment?.perk?.amountSponsored
const transactionUsd =
parseFloat(qrPayment?.details?.paymentAgainstAmount || '0') || 0
const percentage =
amountSponsored && transactionUsd > 0
? Math.round((amountSponsored / transactionUsd) * 100)
: qrPayment?.perk?.discountPercentage || 100
if (percentage === 100) {
return 'We paid for this bill! Earn points, climb tiers and unlock even better perks.'
} else if (percentage > 100) {
return `We gave you ${percentage}% back — that's more than you paid! Earn points, climb tiers and unlock even better perks.`
} else {
return `We gave you ${percentage}% cashback! Earn points, climb tiers and unlock even better perks.`

// Tone scales with amount: small = growth nudge, large = celebratory
if (amountSponsored && typeof amountSponsored === 'number') {
if (transactionUsd > 0 && amountSponsored >= transactionUsd) {
return 'We paid for this bill! Earn points, climb tiers and unlock even better perks.'
}
if (amountSponsored < 0.5) {
return `You got $${amountSponsored.toFixed(2)} back. The more friends you invite, the more you earn!`
}
if (amountSponsored >= 5) {
return `We gave you $${amountSponsored.toFixed(2)} back! Your points are paying off.`
}
return `We gave you $${amountSponsored.toFixed(2)} back! Invite friends to unlock bigger rewards.`
}

return 'We gave you cashback! Earn points, climb tiers and unlock even better perks.'
})()}
</p>
</div>
Expand Down
60 changes: 22 additions & 38 deletions src/app/api/health/backend/route.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
import { NextResponse } from 'next/server'
import { fetchWithSentry } from '@/utils/sentry.utils'
import { PEANUT_API_URL } from '@/constants/general.consts'

export const dynamic = 'force-dynamic'
export const revalidate = 0
export const fetchCache = 'force-no-store'

const NO_CACHE_HEADERS = {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
Pragma: 'no-cache',
Expires: '0',
'Surrogate-Control': 'no-store',
}

/**
* Health check for Peanut API backend
* Tests connectivity to the main peanut-api-ts backend service
* Health check for Peanut API backend.
* Uses the backend's dedicated /healthz endpoint (checks DB connectivity).
* Uses plain fetch to avoid health check errors polluting Sentry.
*/
export async function GET() {
const startTime = Date.now()
Expand All @@ -23,28 +30,26 @@ export async function GET() {
error: 'PEANUT_API_URL not configured',
responseTime: Date.now() - startTime,
},
{ status: 500 }
{ status: 500, headers: NO_CACHE_HEADERS }
)
}

// Test backend connectivity by fetching a specific user endpoint
const backendTestStart = Date.now()
const backendResponse = await fetchWithSentry(`${PEANUT_API_URL}/users/username/hugo`, {
const backendResponse = await fetch(`${PEANUT_API_URL}/healthz`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
next: { revalidate: 0 },
signal: AbortSignal.timeout(8000),
})

const backendResponseTime = Date.now() - backendTestStart

// Backend is healthy if we get any response (200, 404, etc.) - what matters is connectivity
if (!backendResponse.ok && backendResponse.status >= 500) {
throw new Error(`Backend API returned server error ${backendResponse.status}`)
if (!backendResponse.ok) {
const errorData = await backendResponse.json().catch(() => null)
throw new Error(errorData?.error || `Backend /healthz returned ${backendResponse.status}`)
}

const healthData = await backendResponse.json()
const totalResponseTime = Date.now() - startTime

return NextResponse.json(
Expand All @@ -54,29 +59,16 @@ export async function GET() {
timestamp: new Date().toISOString(),
responseTime: totalResponseTime,
details: {
apiConnectivity: {
healthz: {
status: 'healthy',
responseTime: backendResponseTime,
httpStatus: backendResponse.status,
apiUrl: PEANUT_API_URL,
testEndpoint: '/users/username/hugo',
message: backendResponse.ok
? 'Backend responding normally'
: backendResponse.status === 404
? 'Backend accessible (user not found as expected)'
: 'Backend accessible',
dbConnected: healthData.dbConnected ?? true,
},
},
},
{
status: 200,
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
Pragma: 'no-cache',
Expires: '0',
'Surrogate-Control': 'no-store',
},
}
{ status: 200, headers: NO_CACHE_HEADERS }
)
} catch (error) {
const totalResponseTime = Date.now() - startTime
Expand All @@ -89,15 +81,7 @@ export async function GET() {
error: error instanceof Error ? error.message : 'Unknown error',
responseTime: totalResponseTime,
},
{
status: 500,
headers: {
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
Pragma: 'no-cache',
Expires: '0',
'Surrogate-Control': 'no-store',
},
}
{ status: 500, headers: NO_CACHE_HEADERS }
)
}
}
Loading
Loading