diff --git a/public/onesignal/OneSignalSDKWorker.js b/public/onesignal/OneSignalSDKWorker.js
index 7032e7a66..4856c9450 100644
--- a/public/onesignal/OneSignalSDKWorker.js
+++ b/public/onesignal/OneSignalSDKWorker.js
@@ -1,3 +1,7 @@
-// use the stable v16 service worker path per onesignal docs
-// note: onesignal does not publish minor-pinned sw paths; use v16 channel
+// Version Pinning: OneSignal uses major version channels (v16) for their CDN.
+// Per OneSignal documentation, they do not publish minor or patch-pinned service worker URLs.
+// The v16 channel receives security updates and bug fixes automatically while maintaining
+// API compatibility. This is the recommended approach per OneSignal's best practices.
+// For stricter version control, we can consider self-hosting the SDK, but this requires manual updates.
+// Reference: https://documentation.onesignal.com/docs/web-push-quickstart
importScripts('https://cdn.onesignal.com/sdks/web/v16/OneSignalSDK.sw.js')
diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx
index e44b0b963..2a98ec647 100644
--- a/src/app/(mobile-ui)/layout.tsx
+++ b/src/app/(mobile-ui)/layout.tsx
@@ -20,9 +20,7 @@ import { Banner } from '@/components/Global/Banner'
import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType'
import { useSetupStore } from '@/redux/hooks'
import ForceIOSPWAInstall from '@/components/ForceIOSPWAInstall'
-
-// Allow access to some public paths without authentication
-const publicPathRegex = /^\/(request\/pay|claim|pay\/.+$|support|invite|dev)/
+import { PUBLIC_ROUTES_REGEX } from '@/constants/routes'
const Layout = ({ children }: { children: React.ReactNode }) => {
const pathName = usePathname()
@@ -78,7 +76,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
}, [])
// Allow access to public paths without authentication
- const isPublicPath = publicPathRegex.test(pathName)
+ const isPublicPath = PUBLIC_ROUTES_REGEX.test(pathName)
useEffect(() => {
if (!isPublicPath && !isFetchingUser && !user) {
diff --git a/src/app/(mobile-ui)/qr/[code]/page.tsx b/src/app/(mobile-ui)/qr/[code]/page.tsx
new file mode 100644
index 000000000..4a5e6673a
--- /dev/null
+++ b/src/app/(mobile-ui)/qr/[code]/page.tsx
@@ -0,0 +1,265 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle'
+import Card from '@/components/Global/Card'
+import NavHeader from '@/components/Global/NavHeader'
+import { PEANUT_API_URL } from '@/constants'
+import { useAuth } from '@/context/authContext'
+import { useRouter, useParams } from 'next/navigation'
+import { useCallback, useEffect, useState } from 'react'
+import PeanutLoading from '@/components/Global/PeanutLoading'
+import ErrorAlert from '@/components/Global/ErrorAlert'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { saveRedirectUrl, generateInviteCodeLink, sanitizeRedirectURL } from '@/utils'
+import { getShakeClass } from '@/utils/perk.utils'
+import Cookies from 'js-cookie'
+import { useRedirectQrStatus } from '@/hooks/useRedirectQrStatus'
+import { useHoldToClaim } from '@/hooks/useHoldToClaim'
+
+export default function RedirectQrClaimPage() {
+ const router = useRouter()
+ const params = useParams()
+ const code = params?.code as string
+ const { user } = useAuth()
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ // Fetch redirect QR status using shared hook
+ const { data: redirectQrData, isLoading: isCheckingStatus, error: redirectQrError } = useRedirectQrStatus(code)
+
+ // Handle redirects based on QR status and authentication
+ useEffect(() => {
+ // Wait for QR status to load
+ if (isCheckingStatus || !redirectQrData) {
+ return
+ }
+
+ // If QR is already claimed, redirect to the target URL
+ if (redirectQrData.claimed && redirectQrData.redirectUrl) {
+ // Sanitize redirect URL to prevent open redirect attacks
+ // For same-origin URLs, sanitizeRedirectURL returns safe path
+ // For external URLs, it returns null (we handle separately)
+ const sanitizedPath = sanitizeRedirectURL(redirectQrData.redirectUrl)
+
+ if (sanitizedPath) {
+ // Internal redirect - use sanitized path with Next.js router
+ router.push(sanitizedPath)
+ } else {
+ // External redirect - validate it's expected domain before redirecting
+ try {
+ const url = new URL(redirectQrData.redirectUrl)
+ // Allow external redirects ONLY for trusted domains (peanut.me)
+ // This relies on backend validation during QR claiming
+ if (url.hostname.includes('peanut.me') || url.hostname.includes('localhost')) {
+ window.location.href = redirectQrData.redirectUrl
+ } else {
+ console.error('Untrusted external redirect blocked:', redirectQrData.redirectUrl)
+ setError('Invalid QR code destination.')
+ }
+ } catch (error) {
+ console.error('Invalid redirect URL:', redirectQrData.redirectUrl)
+ setError('Invalid QR code destination.')
+ }
+ }
+ return
+ }
+
+ // QR is not claimed - check authentication
+ if (!user) {
+ // User not logged in - redirect to setup to create account/login
+ saveRedirectUrl()
+ router.push('/setup')
+ }
+ }, [isCheckingStatus, redirectQrData, user, router])
+
+ const handleClaim = useCallback(async () => {
+ // Auth check is already handled by useEffect above
+ // If we reach here, user is authenticated
+ setIsLoading(true)
+ setError(null)
+
+ try {
+ // Generate invite link with correct 3-digit suffix
+ const username = user?.user?.username
+ if (!username) {
+ throw new Error('Username not found')
+ }
+
+ const { inviteLink } = generateInviteCodeLink(username)
+
+ const response = await fetch(`${PEANUT_API_URL}/qr/${code}/claim`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${Cookies.get('jwt-token')}`,
+ },
+ body: JSON.stringify({
+ targetUrl: inviteLink, // Pass the correctly formatted invite link
+ }),
+ })
+
+ const data = await response.json()
+
+ if (!response.ok) {
+ // Log backend error for debugging but show generic message to user
+ console.error('Backend claim error:', data.message || data.error)
+ throw new Error('Failed to claim QR code. Please try again.')
+ }
+
+ // Success! Show success page, then redirect to invite (which goes to profile for logged-in users)
+ router.push(`/qr/${code}/success`)
+ } catch (err: any) {
+ console.error('Error claiming QR:', err)
+ // Always show generic error message (don't expose backend details)
+ setError('Failed to claim QR code. Please try again.')
+ } finally {
+ setIsLoading(false)
+ }
+ }, [code, router, user])
+
+ // Hold-to-claim mechanics with shake animation
+ const { holdProgress, isShaking, shakeIntensity, buttonProps } = useHoldToClaim({
+ onComplete: handleClaim,
+ disabled: isLoading,
+ })
+
+ // Show loading while checking status or if we're in the process of redirecting
+ if (isCheckingStatus || (redirectQrData?.claimed && redirectQrData?.redirectUrl)) {
+ return (
+
+
+
+
+
+
+ )
+ }
+
+ // If not logged in and QR is unclaimed, the useEffect above will redirect to setup
+ // This loading screen will show briefly during that redirect
+ if (!user) {
+ return (
+
+
+
+
+
+
+ )
+ }
+
+ // Show error only if there's an actual error or QR is not available (and not claimed)
+ if (redirectQrError || !redirectQrData || (!redirectQrData.available && !redirectQrData.claimed)) {
+ // Log error for debugging but don't expose details to user
+ if (redirectQrError) {
+ console.error('QR status check error:', redirectQrError)
+ }
+ return (
+
+
+
+
+
+
+
+
+
+
+
QR Code Unavailable
+
+ {redirectQrData?.claimed
+ ? 'This QR code has already been claimed by another user.'
+ : 'This QR code is not available for claiming.'}
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {/* QR Code Visual */}
+
+
+
+
+
+
+
+
Claim Your Code
+
+ This QR code will be permanently linked to your Peanut profile. Anyone who scans it will be
+ able to use your invite.
+
+
+
+
+ {/* How it works */}
+
+
How it works
+
+
+
+ 1
+
+
You claim this QR code and it becomes yours forever
+
+
+
+ 2
+
+
Put the sticker anywhere you want people to find you
+
+
+
+ 3
+
+
+ They can join Peanut with your invite and contribute towards your points, forever.
+
+
+
+
+
+ {/* Important note */}
+
+
+
+
+ Important: Once claimed, this QR code cannot be transferred or changed.
+
+
+
+
+ {/* Claim button - Hold to claim */}
+
+
+ {error && }
+
+
+ )
+}
diff --git a/src/app/(mobile-ui)/qr/[code]/success/page.tsx b/src/app/(mobile-ui)/qr/[code]/success/page.tsx
new file mode 100644
index 000000000..1f2e50eed
--- /dev/null
+++ b/src/app/(mobile-ui)/qr/[code]/success/page.tsx
@@ -0,0 +1,121 @@
+'use client'
+
+import { Button } from '@/components/0_Bruddle'
+import Card from '@/components/Global/Card'
+import NavHeader from '@/components/Global/NavHeader'
+import { BASE_URL } from '@/constants'
+import { useRouter, useParams } from 'next/navigation'
+import { useEffect } from 'react'
+import PeanutLoading from '@/components/Global/PeanutLoading'
+import { Icon } from '@/components/Global/Icons/Icon'
+import { confettiPresets } from '@/utils/confetti'
+import { useRedirectQrStatus } from '@/hooks/useRedirectQrStatus'
+import QRCodeWrapper from '@/components/Global/QRCodeWrapper'
+import { useToast } from '@/components/0_Bruddle/Toast'
+
+export default function RedirectQrSuccessPage() {
+ const router = useRouter()
+ const params = useParams()
+ const code = params?.code as string
+ const toast = useToast()
+
+ // Fetch redirect QR details using shared hook
+ const { data: redirectQrData, isLoading } = useRedirectQrStatus(code)
+
+ const qrUrl = `${BASE_URL}/qr/${code}`
+
+ // Trigger confetti on mount
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ confettiPresets.success()
+ }, 300)
+
+ return () => clearTimeout(timer)
+ }, [])
+
+ if (isLoading || !redirectQrData) {
+ return (
+
+
+
+
+
+
+ )
+ }
+
+ return (
+
+
+
+ {/* Title */}
+
+
This QR is now yours!
+
Your sticker is now linked to your profile forever.
+
+
+ {/* QR Code Display */}
+
+
+
+
+ {/* Sticker Info Card */}
+
+
+
+
+
Put it anywhere!
+
+ Stick it on your laptop, water bottle, or anywhere you want people to find you. Anyone
+ who scans will be able to join Peanut with your invite and contribute towards your
+ points forever.
+
+
+
+
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+ )
+}
diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx
index a1dcf33b1..64b5c73df 100644
--- a/src/app/[...recipient]/page.tsx
+++ b/src/app/[...recipient]/page.tsx
@@ -7,17 +7,31 @@ import { isAddress } from 'viem'
import { printableAddress, resolveAddressToUsername } from '@/utils'
import { chargesApi } from '@/services/charges'
import { parseAmountAndToken } from '@/lib/url-parser/parser'
+import { notFound } from 'next/navigation'
+import { RESERVED_ROUTES } from '@/constants/routes'
type PageProps = {
params: Promise<{ recipient?: string[] }>
}
export async function generateMetadata({ params, searchParams }: any) {
- let title = 'Request Payment | Peanut'
- const siteUrl: string = (await getOrigin()) || BASE_URL // getOrigin for getting the origin of the site regardless of its a vercel preview or not
const resolvedSearchParams = await searchParams
const resolvedParams = await params
+ // Guard: Don't generate metadata for reserved routes (handled by their specific routes)
+ const firstSegment = resolvedParams.recipient?.[0]?.toLowerCase()
+ if (firstSegment && RESERVED_ROUTES.includes(firstSegment)) {
+ return {}
+ }
+
+ // Guard: Ensure recipient exists
+ if (!resolvedParams.recipient?.[0]) {
+ return {}
+ }
+
+ let title = 'Request Payment | Peanut'
+ const siteUrl: string = (await getOrigin()) || BASE_URL // getOrigin for getting the origin of the site regardless of its a vercel preview or not
+
let recipient = resolvedParams.recipient[0].toLowerCase()
if (recipient.includes('%40') || recipient.includes('@')) {
@@ -165,6 +179,13 @@ export default function Page(props: PageProps) {
const params = use(props.params)
const recipient = params.recipient ?? []
+ // Guard: Reserved routes should be handled by their specific route files
+ // If we reach here, it means Next.js routing didn't catch it properly
+ const firstSegment = recipient[0]?.toLowerCase()
+ if (firstSegment && RESERVED_ROUTES.includes(firstSegment)) {
+ notFound()
+ }
+
return (
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 430beb702..a62095b59 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -69,6 +69,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
+ {/* Note: Google Tag Manager (gtag.js) does not support version pinning.*/}
{process.env.NODE_ENV !== 'development' && process.env.NEXT_PUBLIC_GA_KEY && (
<>