From d10740314b9816ed5f25cca98f9d1584781b57e6 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 3 Nov 2025 02:53:44 -0300 Subject: [PATCH 1/7] qr sheets --- src/app/(mobile-ui)/qr/[code]/page.tsx | 207 ++++++++++++++++++ .../(mobile-ui)/qr/[code]/success/page.tsx | 153 +++++++++++++ src/app/[...recipient]/page.tsx | 49 ++++- src/components/Global/DirectSendQR/index.tsx | 33 ++- src/components/Global/DirectSendQR/utils.ts | 10 +- src/components/Invites/InvitesPage.tsx | 10 +- src/hooks/useHoldToClaim.ts | 200 +++++++++++++++++ src/hooks/useRedirectQrStatus.ts | 30 +++ src/middleware.ts | 1 + 9 files changed, 687 insertions(+), 6 deletions(-) create mode 100644 src/app/(mobile-ui)/qr/[code]/page.tsx create mode 100644 src/app/(mobile-ui)/qr/[code]/success/page.tsx create mode 100644 src/hooks/useHoldToClaim.ts create mode 100644 src/hooks/useRedirectQrStatus.ts 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..632a058b0 --- /dev/null +++ b/src/app/(mobile-ui)/qr/[code]/page.tsx @@ -0,0 +1,207 @@ +'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 } 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) + + // If already claimed, redirect to target URL + useEffect(() => { + if (redirectQrData?.claimed && redirectQrData?.redirectUrl) { + window.location.href = redirectQrData.redirectUrl + } + }, [redirectQrData]) + + // Check authentication and redirect if needed + useEffect(() => { + if (!isCheckingStatus && !user) { + // Save current URL to redirect back after login + saveRedirectUrl() + router.push('/setup') + } + }, [user, isCheckingStatus, 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 { + 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({}), // Empty body to prevent "Unexpected end of JSON input" error + }) + + const data = await response.json() + + if (!response.ok) { + throw new Error(data.message || 'Failed to claim QR code') + } + + // Success! Redirect to success page + router.push(`/qr/${code}/success`) + } catch (err: any) { + console.error('Error claiming QR:', err) + setError(err.message || 'Failed to claim QR code. Please try again.') + } finally { + setIsLoading(false) + } + }, [code, router]) + + // Hold-to-claim mechanics with shake animation + const { holdProgress, isShaking, shakeIntensity, buttonProps } = useHoldToClaim({ + onComplete: handleClaim, + disabled: isLoading, + }) + + if (isCheckingStatus || !user) { + return ( +
+ +
+ +
+
+ ) + } + + if (redirectQrError || !redirectQrData || !redirectQrData.available) { + return ( +
+ +
+ +
+
+ +
+
+
+

QR Code Unavailable

+

+ {redirectQrData?.claimed + ? 'This QR code has already been claimed by another user.' + : redirectQrError + ? 'Failed to check QR code status. Please try again.' + : '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 +
+

+ Anyone scanning it can send you money or connect with you +

+
+
+
+ + {/* Important note */} + +
+ +

+ Important: Each user can only claim one QR code, and once claimed, it + 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..100c6a262 --- /dev/null +++ b/src/app/(mobile-ui)/qr/[code]/success/page.tsx @@ -0,0 +1,153 @@ +'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 { useAuth } from '@/context/authContext' +import { useRouter, useParams } from 'next/navigation' +import { useEffect, useMemo } from 'react' +import PeanutLoading from '@/components/Global/PeanutLoading' +import { Icon } from '@/components/Global/Icons/Icon' +import { confettiPresets } from '@/utils/confetti' +import QRCode from 'react-qr-code' +import { useRedirectQrStatus } from '@/hooks/useRedirectQrStatus' + +export default function RedirectQrSuccessPage() { + const router = useRouter() + const params = useParams() + const code = params?.code as string + const { user } = useAuth() + + // Fetch redirect QR details using shared hook + const { data: redirectQrData, isLoading } = useRedirectQrStatus(code) + + // Extract invite code from redirect URL + const inviteCode = useMemo(() => { + if (!redirectQrData?.redirectUrl) return '' + try { + const url = new URL(redirectQrData.redirectUrl) + return url.searchParams.get('code') || '' + } catch { + return '' + } + }, [redirectQrData?.redirectUrl]) + + // Trigger confetti on mount + useEffect(() => { + const timer = setTimeout(() => { + confettiPresets.success() + }, 300) + + return () => clearTimeout(timer) + }, []) + + if (isLoading || !redirectQrData) { + return ( +
+ +
+ +
+
+ ) + } + + const qrUrl = `${BASE_URL}/qr/${code}` + + return ( +
+ +
+ {/* Success Message */} + +
+
+ +
+
+
+

QR Code Claimed!

+

+ This QR code is now permanently linked to your profile. Share it with anyone to receive + payments instantly. +

+
+
+ + {/* QR Code Display */} + +
+

Your Personal QR Code

+

+ Anyone who scans this will be able to connect with you +

+
+ + {/* QR Code */} +
+
+ +
+
+ + {/* QR URL */} +
+

Share this link:

+
+

{qrUrl}

+
+
+
+ + {/* Info Card */} + +
+ +
+

Your QR code is ready!

+

+ Put this sticker on your laptop, water bottle, or anywhere you want people to find you. + They can scan it to send you money or connect with you on Peanut. +

+
+
+
+ + {/* Action Buttons */} +
+ + +
+
+
+ ) +} diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx index a1dcf33b1..3b702adaf 100644 --- a/src/app/[...recipient]/page.tsx +++ b/src/app/[...recipient]/page.tsx @@ -7,17 +7,55 @@ 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' + +// Reserved routes that should not be handled by the catch-all recipient route +// These include routes with dedicated Next.js route files AND paths from redirects.json/middleware +const RESERVED_ROUTES = [ + // Routes with dedicated Next.js route files + 'qr', + 'api', + 'setup', + 'home', + 'history', + 'settings', + 'points', + // Routes from redirects.json (static redirects) + 'docs', + 'packet', + 'create-packet', + 'batch', + 'raffle', + 'pioneers', + 'pints', + 'events', + 'foodie', + // Other common routes + 'claim', + 'pay', + 'request', + 'invite', + 'support', + 'dev', +] 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 {} + } + + 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 +203,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/components/Global/DirectSendQR/index.tsx b/src/components/Global/DirectSendQR/index.tsx index 1d5c8b242..2cffa0c34 100644 --- a/src/components/Global/DirectSendQR/index.tsx +++ b/src/components/Global/DirectSendQR/index.tsx @@ -246,12 +246,41 @@ export default function DirectSendQr({ switch (qrType) { case EQrType.PEANUT_URL: { + // Extract path by removing domain (handle with/without protocol and www) let path = originalData - path = path.substring(BASE_URL.length) + .replace(/^https?:\/\/(www\.)?/, '') // Remove protocol and www + .replace(/^[^/]+/, '') // Remove domain + if (!path.startsWith('/')) { path = '/' + path } - redirectUrl = path + + // Special handling for /qr/ paths (redirect QR codes - user-tied QRs) + if (path.startsWith('/qr/')) { + const redirectQrCode = path.substring(4) // Remove '/qr/' prefix + + // Check redirect QR status (single endpoint for speed) + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_PEANUT_API_URL}/qr/${redirectQrCode}` + ) + const data = await response.json() + + if (data.claimed && data.redirectUrl) { + // Redirect QR is claimed, redirect to target URL + redirectUrl = data.redirectUrl + } else { + // Redirect QR is available, go to claim/redirect page + redirectUrl = `/qr/${redirectQrCode}` + } + } catch (error) { + console.error('Error checking redirect QR:', error) + // Fallback: redirect to claim page + redirectUrl = `/qr/${redirectQrCode}` + } + } else { + redirectUrl = path + } } break case EQrType.EVM_ADDRESS: diff --git a/src/components/Global/DirectSendQR/utils.ts b/src/components/Global/DirectSendQR/utils.ts index 572bcddaf..1c37a990d 100644 --- a/src/components/Global/DirectSendQR/utils.ts +++ b/src/components/Global/DirectSendQR/utils.ts @@ -108,9 +108,17 @@ const REGEXES_BY_TYPE: { [key in QrType]?: RegExp } = { export const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL! export function recognizeQr(data: string): QrType | null { - if (data.startsWith(BASE_URL)) { + // Normalize the data for comparison (remove protocol and www) + const normalizedData = data.toLowerCase().replace(/^https?:\/\/(www\.)?/, '') + const normalizedBaseUrl = BASE_URL.toLowerCase().replace(/^https?:\/\/(www\.)?/, '') + + // Check if it's a Peanut URL: + // 1. Matches BASE_URL (works for tests and production) + // 2. OR explicitly matches peanut.me (works on localhost with real QR codes) + if (normalizedData.startsWith(normalizedBaseUrl) || normalizedData.startsWith('peanut.me/')) { return EQrType.PEANUT_URL } + if (isAddress(data)) { return EQrType.EVM_ADDRESS } diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index c3439b028..f17fa66a5 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -1,5 +1,5 @@ 'use client' -import React, { Suspense } from 'react' +import React, { Suspense, useEffect } from 'react' import PeanutLoading from '../Global/PeanutLoading' import ValidationErrorView from '../Payment/Views/Error.validation.view' import InvitesPageLayout from './InvitesPageLayout' @@ -36,6 +36,14 @@ function InvitePageContent() { enabled: !!inviteCode, }) + // Redirect logged-in users who already have app access to the inviter's profile + // Users without app access should stay on this page to claim the invite and get access + useEffect(() => { + if (user?.user && user?.user.hasAppAccess && inviteCodeData?.success && inviteCodeData?.username) { + router.push(`/${inviteCodeData.username}`) + } + }, [user, inviteCodeData, router]) + const handleClaimInvite = async () => { if (inviteCode) { dispatch(setupActions.setInviteCode(inviteCode)) diff --git a/src/hooks/useHoldToClaim.ts b/src/hooks/useHoldToClaim.ts new file mode 100644 index 000000000..4618547ef --- /dev/null +++ b/src/hooks/useHoldToClaim.ts @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { PERK_HOLD_DURATION_MS } from '@/constants' + +export type ShakeIntensity = 'none' | 'weak' | 'medium' | 'strong' | 'intense' + +interface UseHoldToClaimOptions { + onComplete: () => void + holdDuration?: number + disabled?: boolean +} + +interface UseHoldToClaimReturn { + holdProgress: number + isShaking: boolean + shakeIntensity: ShakeIntensity + startHold: () => void + cancelHold: () => void + buttonProps: { + onPointerDown: () => void + onPointerUp: () => void + onPointerLeave: () => void + onKeyDown: (e: React.KeyboardEvent) => void + onKeyUp: (e: React.KeyboardEvent) => void + onContextMenu: (e: React.MouseEvent) => void + className: string + style: React.CSSProperties + } +} + +/** + * Custom hook for hold-to-claim button interactions + * Provides progress tracking, shake animation, haptic feedback, and accessibility support + */ +export function useHoldToClaim({ + onComplete, + holdDuration = PERK_HOLD_DURATION_MS, + disabled = false, +}: UseHoldToClaimOptions): UseHoldToClaimReturn { + const [holdProgress, setHoldProgress] = useState(0) + const [isShaking, setIsShaking] = useState(false) + const [shakeIntensity, setShakeIntensity] = useState('none') + const holdTimerRef = useRef(null) + const progressIntervalRef = useRef(null) + const holdStartTimeRef = useRef(null) + + // Cleanup timers on unmount + useEffect(() => { + return () => { + if (holdTimerRef.current) clearTimeout(holdTimerRef.current) + if (progressIntervalRef.current) clearInterval(progressIntervalRef.current) + holdStartTimeRef.current = null + } + }, []) + + const cancelHold = useCallback(() => { + const PREVIEW_DURATION_MS = 500 + + // Calculate how long the user held + const elapsed = holdStartTimeRef.current ? Date.now() - holdStartTimeRef.current : 0 + + // Clear the completion timer + if (holdTimerRef.current) clearTimeout(holdTimerRef.current) + holdTimerRef.current = null + + // If it was a quick tap, let the preview animation continue for 500ms before resetting + if (elapsed > 0 && elapsed < PREVIEW_DURATION_MS) { + const remainingPreviewTime = PREVIEW_DURATION_MS - elapsed + + // Let animations continue for the preview duration + const resetTimer = setTimeout(() => { + // Clean up after preview + if (progressIntervalRef.current) clearInterval(progressIntervalRef.current) + progressIntervalRef.current = null + setHoldProgress(0) + setIsShaking(false) + setShakeIntensity('none') + holdStartTimeRef.current = null + + if ('vibrate' in navigator) { + navigator.vibrate(0) + } + }, remainingPreviewTime) + + holdTimerRef.current = resetTimer + } else { + // Released after preview duration - reset immediately + if (progressIntervalRef.current) clearInterval(progressIntervalRef.current) + progressIntervalRef.current = null + setHoldProgress(0) + setIsShaking(false) + setShakeIntensity('none') + holdStartTimeRef.current = null + + if ('vibrate' in navigator) { + navigator.vibrate(0) + } + } + }, []) + + const startHold = useCallback(() => { + if (disabled) return + + setHoldProgress(0) + setIsShaking(true) + + const startTime = Date.now() + holdStartTimeRef.current = startTime + let lastIntensity: ShakeIntensity = 'weak' + + // Update progress and shake intensity + const interval = setInterval(() => { + const elapsed = Date.now() - startTime + const progress = Math.min((elapsed / holdDuration) * 100, 100) + setHoldProgress(progress) + + // Progressive shake intensity with haptic feedback + let newIntensity: ShakeIntensity = 'weak' + if (progress < 25) { + newIntensity = 'weak' + } else if (progress < 50) { + newIntensity = 'medium' + } else if (progress < 75) { + newIntensity = 'strong' + } else { + newIntensity = 'intense' + } + + // Trigger haptic feedback when intensity changes + if (newIntensity !== lastIntensity && 'vibrate' in navigator) { + // Progressive vibration patterns that match shake intensity + switch (newIntensity) { + case 'weak': + navigator.vibrate(50) // Short but noticeable pulse + break + case 'medium': + navigator.vibrate([100, 40, 100]) // Medium pulse pattern + break + case 'strong': + navigator.vibrate([150, 40, 150, 40, 150]) // Strong pulse pattern + break + case 'intense': + navigator.vibrate([200, 40, 200, 40, 200, 40, 200]) // INTENSE pulse pattern + break + } + lastIntensity = newIntensity + } + + setShakeIntensity(newIntensity) + + if (progress >= 100) { + clearInterval(interval) + } + }, 50) + + progressIntervalRef.current = interval + + // Complete after hold duration + const timer = setTimeout(() => { + onComplete() + }, holdDuration) + + holdTimerRef.current = timer + }, [onComplete, holdDuration, disabled]) + + const buttonProps = { + onPointerDown: startHold, + onPointerUp: cancelHold, + onPointerLeave: cancelHold, + onKeyDown: (e: React.KeyboardEvent) => { + if ((e.key === 'Enter' || e.key === ' ') && !disabled) { + e.preventDefault() + startHold() + } + }, + onKeyUp: (e: React.KeyboardEvent) => { + if ((e.key === 'Enter' || e.key === ' ') && !disabled) { + e.preventDefault() + cancelHold() + } + }, + onContextMenu: (e: React.MouseEvent) => { + // Prevent context menu from appearing + e.preventDefault() + }, + className: 'relative touch-manipulation select-none overflow-hidden', + style: { + WebkitTouchCallout: 'none', + WebkitTapHighlightColor: 'transparent', + } as React.CSSProperties, + } + + return { + holdProgress, + isShaking, + shakeIntensity, + startHold, + cancelHold, + buttonProps, + } +} diff --git a/src/hooks/useRedirectQrStatus.ts b/src/hooks/useRedirectQrStatus.ts new file mode 100644 index 000000000..ee299f592 --- /dev/null +++ b/src/hooks/useRedirectQrStatus.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query' +import { PEANUT_API_URL } from '@/constants' + +interface RedirectQrStatusData { + claimed: boolean + available: boolean + redirectUrl?: string + claimedAt?: string +} + +async function fetchRedirectQrStatus(code: string): Promise { + const response = await fetch(`${PEANUT_API_URL}/qr/${code}`) + const result = await response.json() + + if (!response.ok) { + throw new Error(result.message || 'Failed to fetch redirect QR status') + } + + return result +} + +export function useRedirectQrStatus(code: string | null | undefined) { + return useQuery({ + queryKey: ['redirect-qr-status', code], + queryFn: () => fetchRedirectQrStatus(code!), + enabled: !!code, + staleTime: 0, // Always fetch fresh data for redirect QR status + refetchOnMount: true, + }) +} diff --git a/src/middleware.ts b/src/middleware.ts index 3e5b6f045..da813f6ab 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -90,5 +90,6 @@ export const config = { '/p/:path*', '/link/:path*', '/dev/:path*', + '/qr/:path*', ], } From 9142aa54965edee36e0aa658611bace1a19aaf7f Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 3 Nov 2025 13:49:25 -0300 Subject: [PATCH 2/7] temp --- knip.json | 2 +- public/game/peanut-game.html | 5 +- public/onesignal/OneSignalSDKWorker.js | 8 +- src/app/(mobile-ui)/qr/[code]/page.tsx | 43 ++++++++--- .../(mobile-ui)/qr/[code]/success/page.tsx | 74 ++++--------------- src/app/layout.tsx | 1 + src/components/CrispChat.tsx | 5 +- src/components/Invites/InvitesPage.tsx | 20 ++++- 8 files changed, 82 insertions(+), 76 deletions(-) diff --git a/knip.json b/knip.json index b5f4e20e8..82c3a2d67 100644 --- a/knip.json +++ b/knip.json @@ -1,5 +1,5 @@ { - "$schema": "https://unpkg.com/knip@latest/schema.json", + "$schema": "https://unpkg.com/knip@5.37.1/schema.json", "ignore": ["src/assets/**"], "entry": ["src/**/*.{js,jsx,ts,tsx}", "postcss.config.js", "tailwind.config.js"], "project": ["src/**/*.{js,jsx,ts,tsx}", "*.config.{js,ts}"] diff --git a/public/game/peanut-game.html b/public/game/peanut-game.html index 2dabff7f5..b1b0629e2 100644 --- a/public/game/peanut-game.html +++ b/public/game/peanut-game.html @@ -2625,6 +2625,9 @@ 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)/qr/[code]/page.tsx b/src/app/(mobile-ui)/qr/[code]/page.tsx index 632a058b0..d67f81bcf 100644 --- a/src/app/(mobile-ui)/qr/[code]/page.tsx +++ b/src/app/(mobile-ui)/qr/[code]/page.tsx @@ -27,21 +27,41 @@ export default function RedirectQrClaimPage() { // Fetch redirect QR status using shared hook const { data: redirectQrData, isLoading: isCheckingStatus, error: redirectQrError } = useRedirectQrStatus(code) - // If already claimed, redirect to target URL + // If already claimed, redirect to target URL (for both logged in and logged out users) useEffect(() => { if (redirectQrData?.claimed && redirectQrData?.redirectUrl) { - window.location.href = redirectQrData.redirectUrl + // Extract the path from the URL to keep it on the same domain (localhost vs production) + console.log('[QR Claim] QR is claimed, redirecting to:', redirectQrData.redirectUrl) + try { + const url = new URL(redirectQrData.redirectUrl) + const invitePath = `${url.pathname}${url.search}` // e.g., /invite?code=XYZINVITESYOU + console.log('[QR Claim] Extracted invite path:', invitePath) + console.log('[QR Claim] User state:', user ? `logged in as ${user.user?.username}` : 'not logged in') + router.push(invitePath) + } catch (error) { + console.error('[QR Claim] Failed to parse redirectUrl, using full URL', error) + // Fallback to full URL if parsing fails + window.location.href = redirectQrData.redirectUrl + } } - }, [redirectQrData]) + }, [redirectQrData, router, user]) - // Check authentication and redirect if needed + // Check authentication and redirect if needed (only if QR is not claimed) useEffect(() => { - if (!isCheckingStatus && !user) { + console.log('[QR Claim] Auth check:', { + isCheckingStatus, + hasUser: !!user, + hasClaimed: redirectQrData?.claimed, + hasRedirectUrl: !!redirectQrData?.redirectUrl, + }) + + if (!isCheckingStatus && !user && redirectQrData && !redirectQrData.claimed) { + console.log('[QR Claim] QR is unclaimed and user not logged in, redirecting to setup') // Save current URL to redirect back after login saveRedirectUrl() router.push('/setup') } - }, [user, isCheckingStatus, router]) + }, [user, isCheckingStatus, router, redirectQrData]) const handleClaim = useCallback(async () => { // Auth check is already handled by useEffect above @@ -65,7 +85,7 @@ export default function RedirectQrClaimPage() { throw new Error(data.message || 'Failed to claim QR code') } - // Success! Redirect to success page + // 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) @@ -81,7 +101,8 @@ export default function RedirectQrClaimPage() { disabled: isLoading, }) - if (isCheckingStatus || !user) { + // Show loading while checking status, not logged in, or if QR is claimed (redirecting) + if (isCheckingStatus || !user || (redirectQrData?.claimed && redirectQrData?.redirectUrl)) { return (
@@ -92,7 +113,8 @@ export default function RedirectQrClaimPage() { ) } - if (redirectQrError || !redirectQrData || !redirectQrData.available) { + // Show error only if there's an actual error or QR is not available (and not claimed) + if (redirectQrError || !redirectQrData || (!redirectQrData.available && !redirectQrData.claimed)) { return (
@@ -174,8 +196,7 @@ export default function RedirectQrClaimPage() {

- Important: Each user can only claim one QR code, and once claimed, it - cannot be transferred or changed. + Important: Once claimed, this QR code cannot be transferred or changed.

diff --git a/src/app/(mobile-ui)/qr/[code]/success/page.tsx b/src/app/(mobile-ui)/qr/[code]/success/page.tsx index 100c6a262..d0dbec481 100644 --- a/src/app/(mobile-ui)/qr/[code]/success/page.tsx +++ b/src/app/(mobile-ui)/qr/[code]/success/page.tsx @@ -4,34 +4,23 @@ import { Button } from '@/components/0_Bruddle' import Card from '@/components/Global/Card' import NavHeader from '@/components/Global/NavHeader' import { BASE_URL } from '@/constants' -import { useAuth } from '@/context/authContext' import { useRouter, useParams } from 'next/navigation' -import { useEffect, useMemo } from 'react' +import { useEffect } from 'react' import PeanutLoading from '@/components/Global/PeanutLoading' import { Icon } from '@/components/Global/Icons/Icon' import { confettiPresets } from '@/utils/confetti' -import QRCode from 'react-qr-code' import { useRedirectQrStatus } from '@/hooks/useRedirectQrStatus' +import QRCodeWrapper from '@/components/Global/QRCodeWrapper' export default function RedirectQrSuccessPage() { const router = useRouter() const params = useParams() const code = params?.code as string - const { user } = useAuth() // Fetch redirect QR details using shared hook const { data: redirectQrData, isLoading } = useRedirectQrStatus(code) - // Extract invite code from redirect URL - const inviteCode = useMemo(() => { - if (!redirectQrData?.redirectUrl) return '' - try { - const url = new URL(redirectQrData.redirectUrl) - return url.searchParams.get('code') || '' - } catch { - return '' - } - }, [redirectQrData?.redirectUrl]) + const qrUrl = `${BASE_URL}/qr/${code}` // Trigger confetti on mount useEffect(() => { @@ -53,62 +42,31 @@ export default function RedirectQrSuccessPage() { ) } - const qrUrl = `${BASE_URL}/qr/${code}` - return (
- {/* Success Message */} - -
-
- -
-
-
-

QR Code Claimed!

-

- This QR code is now permanently linked to your profile. Share it with anyone to receive - payments instantly. -

-
-
+ {/* Title */} +
+

This QR is now yours!

+

Your sticker is now linked to your profile forever.

+
{/* QR Code Display */} - -
-

Your Personal QR Code

-

- Anyone who scans this will be able to connect with you -

-
- - {/* QR Code */} -
-
- -
-
- - {/* QR URL */} -
-

Share this link:

-
-

{qrUrl}

-
-
-
+
+ +
- {/* Info Card */} + {/* Sticker Info Card */}
-

Your QR code is ready!

+

Put it anywhere!

- Put this sticker on your laptop, water bottle, or anywhere you want people to find you. - They can scan it to send you money or connect with you on Peanut. + 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.

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 && ( <>