diff --git a/src/app/(mobile-ui)/layout.tsx b/src/app/(mobile-ui)/layout.tsx index 024c93328..b8d738d20 100644 --- a/src/app/(mobile-ui)/layout.tsx +++ b/src/app/(mobile-ui)/layout.tsx @@ -93,7 +93,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => { } // Show waitlist page if user doesn't have app access - if (!isFetchingUser && user && !user?.user.hasAppAccess) { + if (!isFetchingUser && user && !user?.user.hasAppAccess && !isPublicPath) { return } diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index 76bbcbe0f..253ff5847 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -48,6 +48,8 @@ import { GuestVerificationModal } from '@/components/Global/GuestVerificationMod import useKycStatus from '@/hooks/useKycStatus' import MantecaFlowManager from './MantecaFlowManager' import ErrorAlert from '@/components/Global/ErrorAlert' +import { invitesApi } from '@/services/invites' +import { EInviteType } from '@/services/services.types' export const InitialClaimLinkView = (props: IClaimScreenProps) => { const { @@ -104,7 +106,7 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { const { claimLink, claimLinkXchain, removeParamStep } = useClaimLink() const { isConnected: isPeanutWallet, address, fetchBalance } = useWallet() const router = useRouter() - const { user } = useAuth() + const { user, fetchUser } = useAuth() const queryClient = useQueryClient() const searchParams = useSearchParams() const prevRecipientType = useRef(null) @@ -164,6 +166,45 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { if (recipient.address === '') return + // If the user doesn't have app access, accept the invite before claiming the link + if (!user?.user.hasAppAccess) { + try { + const inviterUsername = claimLinkData.sender?.username + if (!inviterUsername) { + setErrorState({ + showError: true, + errorMessage: 'Unable to accept invite: missing inviter. Please contact support.', + }) + setLoadingState('Idle') + return + } + const inviteCode = `${inviterUsername}INVITESYOU` + const result = await invitesApi.acceptInvite(inviteCode, EInviteType.PAYMENT_LINK) + if (!result.success) { + console.error('Failed to accept invite') + setErrorState({ + showError: true, + errorMessage: 'Something went wrong. Please try again or contact support.', + }) + setLoadingState('Idle') + return + } + + // fetch user so that we have the latest state and user can access the app. + // We dont need to wait for this, can happen in background. + fetchUser() + } catch (error) { + Sentry.captureException(error) + console.error('Failed to accept invite', error) + setErrorState({ + showError: true, + errorMessage: 'Something went wrong. Please try again or contact support.', + }) + setLoadingState('Idle') + return + } + } + try { setLoadingState('Executing transaction') if (isPeanutWallet) { diff --git a/src/components/Common/ActionList.tsx b/src/components/Common/ActionList.tsx index 04355ddcb..9acf53dd4 100644 --- a/src/components/Common/ActionList.tsx +++ b/src/components/Common/ActionList.tsx @@ -204,7 +204,7 @@ export default function ActionList({ const inviteCode = `${username}INVITESYOU` dispatch(setupActions.setInviteCode(inviteCode)) dispatch(setupActions.setInviteType(EInviteType.PAYMENT_LINK)) - router.push(`/setup?step=signup&redirect_uri=${redirectUri}`) + router.push(`/invite?code=${inviteCode}&redirect_uri=${redirectUri}`) } else { router.push(`/setup?redirect_uri=${redirectUri}`) } @@ -233,7 +233,7 @@ export default function ActionList({ )} {isInviteLink && !userHasAppAccess && username && ( -
+
star{' '}

Invited by {username}, you have early access!

star diff --git a/src/components/Global/EarlyUserModal/index.tsx b/src/components/Global/EarlyUserModal/index.tsx index 979d18e24..2a7692c55 100644 --- a/src/components/Global/EarlyUserModal/index.tsx +++ b/src/components/Global/EarlyUserModal/index.tsx @@ -33,14 +33,18 @@ const EarlyUserModal = () => { <>

- Peanut is now invite-only and you're in! + Peanut is now invite-only. - - Friends you invite → you earn a cut of their fees + + Share your link to earn a share of fees from your invitees and a smaller share when their + friends join. - - Their invites → you earn a cut of the cut + {/* + Friends you invite: you earn a share of their fees. + + Their invites: you earn a smaller share, too. + */}

Promise.resolve(generateInvitesShareText(inviteLink))} diff --git a/src/components/Global/GuestLoginCta/index.tsx b/src/components/Global/GuestLoginCta/index.tsx index 121be04e7..6ad0d9b6e 100644 --- a/src/components/Global/GuestLoginCta/index.tsx +++ b/src/components/Global/GuestLoginCta/index.tsx @@ -43,7 +43,13 @@ const GuestLoginCta = ({ hideConnectWallet = false, view }: GuestLoginCtaProps) const redirect_uri = searchParams.get('redirect_uri') if (redirect_uri) { const sanitizedRedirectUrl = sanitizeRedirectURL(redirect_uri) - router.push(sanitizedRedirectUrl) + // Only redirect if the URL is safe (same-origin) + if (sanitizedRedirectUrl) { + router.push(sanitizedRedirectUrl) + } else { + // If redirect_uri was invalid, stay on current page + Sentry.captureException(`Invalid redirect URL ${redirect_uri}`) + } } } catch (e) { toast.error('Error logging in') diff --git a/src/components/Invites/InvitesPage.tsx b/src/components/Invites/InvitesPage.tsx index 91cf12b0d..c3439b028 100644 --- a/src/components/Invites/InvitesPage.tsx +++ b/src/components/Invites/InvitesPage.tsx @@ -14,14 +14,17 @@ import { setupActions } from '@/redux/slices/setup-slice' import { useAuth } from '@/context/authContext' import { EInviteType } from '@/services/services.types' import { saveToCookie } from '@/utils' +import { useLogin } from '@/hooks/useLogin' function InvitePageContent() { const searchParams = useSearchParams() const inviteCode = searchParams.get('code') - const { logoutUser, isLoggingOut, user } = useAuth() + const redirectUri = searchParams.get('redirect_uri') + const { user } = useAuth() const dispatch = useAppDispatch() const router = useRouter() + const { handleLoginClick, isLoggingIn } = useLogin() const { data: inviteCodeData, @@ -38,15 +41,12 @@ function InvitePageContent() { dispatch(setupActions.setInviteCode(inviteCode)) dispatch(setupActions.setInviteType(EInviteType.PAYMENT_LINK)) saveToCookie('inviteCode', inviteCode) // Save to cookies as well, so that if user installs PWA, they can still use the invite code - router.push('/setup?step=signup') - } - } - - const handleLogout = () => { - if (user) { - logoutUser() - } else { - router.push('/setup') + if (redirectUri) { + const encodedRedirectUri = encodeURIComponent(redirectUri) + router.push('/setup?step=signup&redirect_uri=' + encodedRedirectUri) + } else { + router.push('/setup?step=signup') + } } } @@ -86,9 +86,11 @@ function InvitePageContent() { Claim your spot - + {!user?.user && ( + + )}
diff --git a/src/components/Payment/PaymentForm/index.tsx b/src/components/Payment/PaymentForm/index.tsx index 2e6101620..2f3d8d0eb 100644 --- a/src/components/Payment/PaymentForm/index.tsx +++ b/src/components/Payment/PaymentForm/index.tsx @@ -33,6 +33,8 @@ import { useUserInteractions } from '@/hooks/useUserInteractions' import { useUserByUsername } from '@/hooks/useUserByUsername' import { PaymentFlow } from '@/app/[...recipient]/client' import MantecaFulfillment from '../Views/MantecaFulfillment.view' +import { invitesApi } from '@/services/invites' +import { EInviteType } from '@/services/services.types' export type PaymentFlowProps = { isExternalWalletFlow?: boolean @@ -66,7 +68,7 @@ export const PaymentForm = ({ }: PaymentFormProps) => { const dispatch = useAppDispatch() const router = useRouter() - const { user } = useAuth() + const { user, fetchUser } = useAuth() const { requestDetails, chargeDetails, daimoError, error: paymentStoreError, attachmentOptions } = usePaymentStore() const { setShowExternalWalletFulfillMethods, @@ -91,6 +93,8 @@ export const PaymentForm = ({ const [inputTokenAmount, setInputTokenAmount] = useState( chargeDetails?.tokenAmount || requestDetails?.tokenAmount || amount || '' ) + const [isAcceptingInvite, setIsAcceptingInvite] = useState(false) + const [inviteError, setInviteError] = useState(false) // states const [disconnectWagmiModal, setDisconnectWagmiModal] = useState(false) @@ -108,8 +112,9 @@ export const PaymentForm = ({ const error = useMemo(() => { if (paymentStoreError) return ErrorHandler(paymentStoreError) if (initiatorError) return ErrorHandler(initiatorError) + if (inviteError) return 'Something went wrong. Please try again or contact support.' return null - }, [paymentStoreError, initiatorError]) + }, [paymentStoreError, initiatorError, inviteError]) const { selectedTokenPrice, @@ -314,8 +319,43 @@ export const PaymentForm = ({ isActivePeanutWallet, ]) + const handleAcceptInvite = async () => { + try { + setIsAcceptingInvite(true) + const inviteCode = `${recipient?.identifier}INVITESYOU` + const result = await invitesApi.acceptInvite(inviteCode, EInviteType.PAYMENT_LINK) + + if (!result.success) { + console.error('Failed to accept invite') + setInviteError(true) + setIsAcceptingInvite(false) + return false + } + + // fetch user so that we have the latest state and user can access the app. + // We dont need to wait for this, can happen in background. + await fetchUser() + setIsAcceptingInvite(false) + return true + } catch (error) { + console.error('Failed to accept invite', error) + setInviteError(true) + setIsAcceptingInvite(false) + return false + } + } + const handleInitiatePayment = useCallback(async () => { + // clear invite error + if (inviteError) { + setInviteError(false) + } if (isActivePeanutWallet && isInsufficientBalanceError && !isExternalWalletFlow) { + // If the user doesn't have app access, accept the invite before claiming the link + if (recipient.recipientType === 'USERNAME' && !user?.user.hasAppAccess) { + const isAccepted = await handleAcceptInvite() + if (!isAccepted) return + } router.push('/add-money') return } @@ -416,6 +456,8 @@ export const PaymentForm = ({ selectedChainID, inputUsdValue, requestedTokenPrice, + inviteError, + handleAcceptInvite, ]) const getButtonText = () => { @@ -654,7 +696,7 @@ export const PaymentForm = ({ {isPeanutWalletConnected && (!error || isInsufficientBalanceError) && (