Skip to content

Commit 51000ef

Browse files
authored
Merge pull request #1311 from peanutprotocol/fix/guest-invite-link
[TASK-15701] Fix: guest invite link flow
2 parents 2c10e4c + f6f4154 commit 51000ef

14 files changed

Lines changed: 231 additions & 72 deletions

File tree

src/app/(mobile-ui)/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
9393
}
9494

9595
// Show waitlist page if user doesn't have app access
96-
if (!isFetchingUser && user && !user?.user.hasAppAccess) {
96+
if (!isFetchingUser && user && !user?.user.hasAppAccess && !isPublicPath) {
9797
return <JoinWaitlistPage />
9898
}
9999

src/components/Claim/Link/Initial.view.tsx

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ import { GuestVerificationModal } from '@/components/Global/GuestVerificationMod
4848
import useKycStatus from '@/hooks/useKycStatus'
4949
import MantecaFlowManager from './MantecaFlowManager'
5050
import ErrorAlert from '@/components/Global/ErrorAlert'
51+
import { invitesApi } from '@/services/invites'
52+
import { EInviteType } from '@/services/services.types'
5153

5254
export const InitialClaimLinkView = (props: IClaimScreenProps) => {
5355
const {
@@ -104,7 +106,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
104106
const { claimLink, claimLinkXchain, removeParamStep } = useClaimLink()
105107
const { isConnected: isPeanutWallet, address, fetchBalance } = useWallet()
106108
const router = useRouter()
107-
const { user } = useAuth()
109+
const { user, fetchUser } = useAuth()
108110
const queryClient = useQueryClient()
109111
const searchParams = useSearchParams()
110112
const prevRecipientType = useRef<string | null>(null)
@@ -164,6 +166,45 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
164166

165167
if (recipient.address === '') return
166168

169+
// If the user doesn't have app access, accept the invite before claiming the link
170+
if (!user?.user.hasAppAccess) {
171+
try {
172+
const inviterUsername = claimLinkData.sender?.username
173+
if (!inviterUsername) {
174+
setErrorState({
175+
showError: true,
176+
errorMessage: 'Unable to accept invite: missing inviter. Please contact support.',
177+
})
178+
setLoadingState('Idle')
179+
return
180+
}
181+
const inviteCode = `${inviterUsername}INVITESYOU`
182+
const result = await invitesApi.acceptInvite(inviteCode, EInviteType.PAYMENT_LINK)
183+
if (!result.success) {
184+
console.error('Failed to accept invite')
185+
setErrorState({
186+
showError: true,
187+
errorMessage: 'Something went wrong. Please try again or contact support.',
188+
})
189+
setLoadingState('Idle')
190+
return
191+
}
192+
193+
// fetch user so that we have the latest state and user can access the app.
194+
// We dont need to wait for this, can happen in background.
195+
fetchUser()
196+
} catch (error) {
197+
Sentry.captureException(error)
198+
console.error('Failed to accept invite', error)
199+
setErrorState({
200+
showError: true,
201+
errorMessage: 'Something went wrong. Please try again or contact support.',
202+
})
203+
setLoadingState('Idle')
204+
return
205+
}
206+
}
207+
167208
try {
168209
setLoadingState('Executing transaction')
169210
if (isPeanutWallet) {

src/components/Common/ActionList.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export default function ActionList({
204204
const inviteCode = `${username}INVITESYOU`
205205
dispatch(setupActions.setInviteCode(inviteCode))
206206
dispatch(setupActions.setInviteType(EInviteType.PAYMENT_LINK))
207-
router.push(`/setup?step=signup&redirect_uri=${redirectUri}`)
207+
router.push(`/invite?code=${inviteCode}&redirect_uri=${redirectUri}`)
208208
} else {
209209
router.push(`/setup?redirect_uri=${redirectUri}`)
210210
}
@@ -233,7 +233,7 @@ export default function ActionList({
233233
</Button>
234234
)}
235235
{isInviteLink && !userHasAppAccess && username && (
236-
<div className="!mt-6 flex w-full items-center justify-between">
236+
<div className="!mt-6 flex w-full items-center justify-center gap-1 md:gap-2">
237237
<Image src={starStraightImage.src} alt="star" width={20} height={20} />{' '}
238238
<p className="text-center text-sm">Invited by {username}, you have early access!</p>
239239
<Image src={starStraightImage.src} alt="star" width={20} height={20} />

src/components/Global/EarlyUserModal/index.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,18 @@ const EarlyUserModal = () => {
3333
<>
3434
<p className="text-sm text-grey-1">
3535
<span className="block">
36-
Peanut is now <b>invite-only</b> and you're in!
36+
Peanut is now <b>invite-only.</b>
3737
</span>
38-
<span className="mt-2 block">
39-
<b>Friends you invite </b>→ you earn a cut of their fees
38+
<span>
39+
Share your link to earn a share of fees from your invitees and a smaller share when their
40+
friends join.
4041
</span>
41-
<span className="block">
42-
<b> Their invites </b> you earn a cut of the cut
42+
{/* <span className="mt-2 block">
43+
<b>Friends you invite: </b> you earn a share of their fees.
4344
</span>
45+
<span className="block">
46+
<b> Their invites: </b> you earn a smaller share, too.
47+
</span> */}
4448
</p>
4549
<ShareButton
4650
generateText={() => Promise.resolve(generateInvitesShareText(inviteLink))}

src/components/Global/GuestLoginCta/index.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@ const GuestLoginCta = ({ hideConnectWallet = false, view }: GuestLoginCtaProps)
4343
const redirect_uri = searchParams.get('redirect_uri')
4444
if (redirect_uri) {
4545
const sanitizedRedirectUrl = sanitizeRedirectURL(redirect_uri)
46-
router.push(sanitizedRedirectUrl)
46+
// Only redirect if the URL is safe (same-origin)
47+
if (sanitizedRedirectUrl) {
48+
router.push(sanitizedRedirectUrl)
49+
} else {
50+
// If redirect_uri was invalid, stay on current page
51+
Sentry.captureException(`Invalid redirect URL ${redirect_uri}`)
52+
}
4753
}
4854
} catch (e) {
4955
toast.error('Error logging in')

src/components/Invites/InvitesPage.tsx

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,17 @@ import { setupActions } from '@/redux/slices/setup-slice'
1414
import { useAuth } from '@/context/authContext'
1515
import { EInviteType } from '@/services/services.types'
1616
import { saveToCookie } from '@/utils'
17+
import { useLogin } from '@/hooks/useLogin'
1718

1819
function InvitePageContent() {
1920
const searchParams = useSearchParams()
2021
const inviteCode = searchParams.get('code')
21-
const { logoutUser, isLoggingOut, user } = useAuth()
22+
const redirectUri = searchParams.get('redirect_uri')
23+
const { user } = useAuth()
2224

2325
const dispatch = useAppDispatch()
2426
const router = useRouter()
27+
const { handleLoginClick, isLoggingIn } = useLogin()
2528

2629
const {
2730
data: inviteCodeData,
@@ -38,15 +41,12 @@ function InvitePageContent() {
3841
dispatch(setupActions.setInviteCode(inviteCode))
3942
dispatch(setupActions.setInviteType(EInviteType.PAYMENT_LINK))
4043
saveToCookie('inviteCode', inviteCode) // Save to cookies as well, so that if user installs PWA, they can still use the invite code
41-
router.push('/setup?step=signup')
42-
}
43-
}
44-
45-
const handleLogout = () => {
46-
if (user) {
47-
logoutUser()
48-
} else {
49-
router.push('/setup')
44+
if (redirectUri) {
45+
const encodedRedirectUri = encodeURIComponent(redirectUri)
46+
router.push('/setup?step=signup&redirect_uri=' + encodedRedirectUri)
47+
} else {
48+
router.push('/setup?step=signup')
49+
}
5050
}
5151
}
5252

@@ -86,9 +86,11 @@ function InvitePageContent() {
8686
Claim your spot
8787
</Button>
8888

89-
<button disabled={isLoggingOut} onClick={handleLogout} className="text-sm underline">
90-
{isLoggingOut ? 'Please wait...' : 'Already have an account? Log in!'}
91-
</button>
89+
{!user?.user && (
90+
<button disabled={isLoggingIn} onClick={handleLoginClick} className="text-sm underline">
91+
{isLoggingIn ? 'Please wait...' : 'Already have an account? Log in!'}
92+
</button>
93+
)}
9294
</div>
9395
</div>
9496
</div>

src/components/Payment/PaymentForm/index.tsx

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ import { useUserInteractions } from '@/hooks/useUserInteractions'
3333
import { useUserByUsername } from '@/hooks/useUserByUsername'
3434
import { PaymentFlow } from '@/app/[...recipient]/client'
3535
import MantecaFulfillment from '../Views/MantecaFulfillment.view'
36+
import { invitesApi } from '@/services/invites'
37+
import { EInviteType } from '@/services/services.types'
3638

3739
export type PaymentFlowProps = {
3840
isExternalWalletFlow?: boolean
@@ -66,7 +68,7 @@ export const PaymentForm = ({
6668
}: PaymentFormProps) => {
6769
const dispatch = useAppDispatch()
6870
const router = useRouter()
69-
const { user } = useAuth()
71+
const { user, fetchUser } = useAuth()
7072
const { requestDetails, chargeDetails, daimoError, error: paymentStoreError, attachmentOptions } = usePaymentStore()
7173
const {
7274
setShowExternalWalletFulfillMethods,
@@ -91,6 +93,8 @@ export const PaymentForm = ({
9193
const [inputTokenAmount, setInputTokenAmount] = useState<string>(
9294
chargeDetails?.tokenAmount || requestDetails?.tokenAmount || amount || ''
9395
)
96+
const [isAcceptingInvite, setIsAcceptingInvite] = useState(false)
97+
const [inviteError, setInviteError] = useState(false)
9498

9599
// states
96100
const [disconnectWagmiModal, setDisconnectWagmiModal] = useState<boolean>(false)
@@ -108,8 +112,9 @@ export const PaymentForm = ({
108112
const error = useMemo(() => {
109113
if (paymentStoreError) return ErrorHandler(paymentStoreError)
110114
if (initiatorError) return ErrorHandler(initiatorError)
115+
if (inviteError) return 'Something went wrong. Please try again or contact support.'
111116
return null
112-
}, [paymentStoreError, initiatorError])
117+
}, [paymentStoreError, initiatorError, inviteError])
113118

114119
const {
115120
selectedTokenPrice,
@@ -314,8 +319,43 @@ export const PaymentForm = ({
314319
isActivePeanutWallet,
315320
])
316321

322+
const handleAcceptInvite = async () => {
323+
try {
324+
setIsAcceptingInvite(true)
325+
const inviteCode = `${recipient?.identifier}INVITESYOU`
326+
const result = await invitesApi.acceptInvite(inviteCode, EInviteType.PAYMENT_LINK)
327+
328+
if (!result.success) {
329+
console.error('Failed to accept invite')
330+
setInviteError(true)
331+
setIsAcceptingInvite(false)
332+
return false
333+
}
334+
335+
// fetch user so that we have the latest state and user can access the app.
336+
// We dont need to wait for this, can happen in background.
337+
await fetchUser()
338+
setIsAcceptingInvite(false)
339+
return true
340+
} catch (error) {
341+
console.error('Failed to accept invite', error)
342+
setInviteError(true)
343+
setIsAcceptingInvite(false)
344+
return false
345+
}
346+
}
347+
317348
const handleInitiatePayment = useCallback(async () => {
349+
// clear invite error
350+
if (inviteError) {
351+
setInviteError(false)
352+
}
318353
if (isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) {
354+
// If the user doesn't have app access, accept the invite before claiming the link
355+
if (recipient.recipientType === 'USERNAME' && !user?.user.hasAppAccess) {
356+
const isAccepted = await handleAcceptInvite()
357+
if (!isAccepted) return
358+
}
319359
router.push('/add-money')
320360
return
321361
}
@@ -416,6 +456,8 @@ export const PaymentForm = ({
416456
selectedChainID,
417457
inputUsdValue,
418458
requestedTokenPrice,
459+
inviteError,
460+
handleAcceptInvite,
419461
])
420462

421463
const getButtonText = () => {
@@ -654,7 +696,7 @@ export const PaymentForm = ({
654696
{isPeanutWalletConnected && (!error || isInsufficientBalanceError) && (
655697
<Button
656698
variant="purple"
657-
loading={isProcessing}
699+
loading={isAcceptingInvite || isProcessing}
658700
shadowSize="4"
659701
onClick={handleInitiatePayment}
660702
disabled={isButtonDisabled}

src/components/Profile/index.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,7 @@ export const Profile = () => {
7575
label="Identity Verification"
7676
href="/profile/identity-verification"
7777
onClick={() => {
78-
if (isUserKycApproved) {
79-
setIsKycApprovedModalOpen(true)
80-
} else {
81-
setShowInitiateKycModal(true)
82-
}
78+
setShowInitiateKycModal(true)
8379
}}
8480
position="middle"
8581
endIcon={isUserKycApproved ? 'check' : undefined}

src/components/Setup/Views/JoinWaitlist.tsx

Lines changed: 3 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@ import { useSetupFlow } from '@/hooks/useSetupFlow'
1111
import { useAppDispatch } from '@/redux/hooks'
1212
import { setupActions } from '@/redux/slices/setup-slice'
1313
import { invitesApi } from '@/services/invites'
14-
import { useRouter, useSearchParams } from 'next/navigation'
15-
import { getFromLocalStorage, sanitizeRedirectURL } from '@/utils'
1614
import ErrorAlert from '@/components/Global/ErrorAlert'
17-
import { useAuth } from '@/context/authContext'
15+
import { useLogin } from '@/hooks/useLogin'
1816

1917
const JoinWaitlist = () => {
2018
const [inviteCode, setInviteCode] = useState('')
@@ -23,13 +21,10 @@ const JoinWaitlist = () => {
2321
const [isLoading, setisLoading] = useState(false)
2422
const [error, setError] = useState('')
2523

26-
const { handleLogin, isLoggingIn } = useZeroDev()
2724
const toast = useToast()
2825
const { handleNext } = useSetupFlow()
2926
const dispatch = useAppDispatch()
30-
const router = useRouter()
31-
const searchParams = useSearchParams()
32-
const { user } = useAuth()
27+
const { handleLoginClick, isLoggingIn } = useLogin()
3328

3429
const validateInviteCode = async (inviteCode: string): Promise<boolean> => {
3530
try {
@@ -62,34 +57,12 @@ const JoinWaitlist = () => {
6257

6358
const onLoginClick = async () => {
6459
try {
65-
await handleLogin()
60+
await handleLoginClick()
6661
} catch (e) {
6762
handleError(e)
6863
}
6964
}
7065

71-
// Wait for user to be fetched, then redirect
72-
useEffect(() => {
73-
if (user) {
74-
const localStorageRedirect = getFromLocalStorage('redirect')
75-
const redirect_uri = searchParams.get('redirect_uri')
76-
if (redirect_uri) {
77-
let decodedRedirect = redirect_uri
78-
try {
79-
decodedRedirect = decodeURIComponent(redirect_uri)
80-
} catch {}
81-
const sanitizedRedirectUrl = sanitizeRedirectURL(decodedRedirect)
82-
router.push(sanitizedRedirectUrl)
83-
} else if (localStorageRedirect) {
84-
localStorage.removeItem('redirect')
85-
const sanitizedLocalRedirect = sanitizeRedirectURL(String(localStorageRedirect))
86-
router.push(sanitizedLocalRedirect)
87-
} else {
88-
router.push('/home')
89-
}
90-
}
91-
}, [user, router, searchParams])
92-
9366
return (
9467
<div className="flex flex-col gap-4">
9568
<div className="flex items-center gap-2">

src/components/Setup/Views/SetupPasskey.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import * as Sentry from '@sentry/nextjs'
1010
import { WalletProviderType, AccountType } from '@/interfaces'
1111
import { WebAuthnError } from '@simplewebauthn/browser'
1212
import Link from 'next/link'
13-
import { getFromCookie, getFromLocalStorage, sanitizeRedirectURL } from '@/utils'
13+
import { getFromCookie, getFromLocalStorage, getValidRedirectUrl, sanitizeRedirectURL } from '@/utils'
1414
import { POST_SIGNUP_ACTIONS } from '@/components/Global/PostSignupActionManager/post-signup-action.consts'
1515

1616
const SetupPasskey = () => {
@@ -47,9 +47,11 @@ const SetupPasskey = () => {
4747

4848
const redirect_uri = searchParams.get('redirect_uri')
4949
if (redirect_uri) {
50-
const sanitizedRedirectUrl = sanitizeRedirectURL(redirect_uri)
51-
router.push(sanitizedRedirectUrl)
50+
const validRedirectUrl = getValidRedirectUrl(redirect_uri, '/home')
51+
// Only redirect if the URL is safe (same-origin)
52+
router.push(validRedirectUrl)
5253
return
54+
// If redirect_uri was invalid, fall through to other redirect logic
5355
}
5456

5557
const localStorageRedirect = getFromLocalStorage('redirect')
@@ -62,7 +64,8 @@ const SetupPasskey = () => {
6264
router.push('/home')
6365
} else {
6466
localStorage.removeItem('redirect')
65-
router.push(localStorageRedirect)
67+
const validRedirectUrl = getValidRedirectUrl(localStorageRedirect, '/home')
68+
router.push(validRedirectUrl)
6669
}
6770
} else {
6871
router.push('/home')

0 commit comments

Comments
 (0)