From f1d1943978954ca080daa38bd2b75a5c393c6893 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Wed, 25 Jun 2025 23:00:06 +0200 Subject: [PATCH] Redeem invite link when logged in or after logging in --- frontend/src/App.tsx | 8 ++- frontend/src/api/inviteApi.ts | 37 +++++++------ lib/src/Components/Auth/useAuth.tsx | 30 ++++++----- lib/src/Components/Onboarding/InvitePage.tsx | 57 ++++++++++++++++++++ lib/src/Components/Onboarding/index.ts | 1 + lib/src/index.tsx | 1 + lib/src/types/InviteApi.d.ts | 4 ++ 7 files changed, 104 insertions(+), 34 deletions(-) create mode 100644 lib/src/Components/Onboarding/InvitePage.tsx create mode 100644 lib/src/Components/Onboarding/index.ts create mode 100644 lib/src/types/InviteApi.d.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f0de565d..3fbd2747 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ import { Content, AuthProvider, Modal, + InvitePage, LoginPage, SignupPage, Quests, @@ -50,6 +51,7 @@ import { ModalContent } from './ModalContent' import { Landingpage } from './pages/Landingpage' import MapContainer from './pages/MapContainer' import { getBottomRoutes, routes } from './routes/sidebar' +import { InviteApi } from './api/InviteApi' const ProfileForm = lazy(() => import('utopia-ui/Profile').then((mod) => ({ @@ -69,6 +71,8 @@ const UserSettings = lazy(() => })), ) +const inviteApi = new InviteApi() + function App() { const [permissionsApiInstance, setPermissionsApiInstance] = useState() const [tagsApi, setTagsApi] = useState>() @@ -155,7 +159,7 @@ function App() { if (map && layers) return (
- + }> - } /> + } /> } /> } /> { +export class InviteApi { + async validateInvite(inviteId: string): Promise { try { const response = await fetch( - `${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}/${inviteId}`, + `${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}?secret=${inviteId}`, { method: 'GET', mode: 'cors', @@ -18,13 +20,11 @@ export class inviteApi { }, ) - if (!response.ok) { - return null - } + if (!response.ok) return null const data = (await response.json()) as InvitingProfileResponse - return data.id + return data[0].item } catch (error: unknown) { // eslint-disable-next-line no-console console.error('Error fetching inviting profile:', error) @@ -36,16 +36,11 @@ export class inviteApi { } } - async validateInvite(inviteId: string): Promise { - const invitingProfileId = await this.getInvitingProfileId(inviteId) - - return invitingProfileId !== null - } - - async redeemInvite(inviteId: string): Promise { + async redeemInvite(inviteId: string): Promise { try { const response = await fetch( - `${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}/${inviteId}`, + // `${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}?secret=${inviteId}`, + `${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}?secret=${inviteId}`, { method: 'GET', mode: 'cors', @@ -55,7 +50,11 @@ export class inviteApi { }, ) - return response.ok + if (!response.ok) return null + + const data = (await response.json()) as InvitingProfileResponse + + return data[0].item } catch (error: unknown) { // eslint-disable-next-line no-console console.error('Error fetching inviting profile:', error) diff --git a/lib/src/Components/Auth/useAuth.tsx b/lib/src/Components/Auth/useAuth.tsx index 60a8679f..043df072 100644 --- a/lib/src/Components/Auth/useAuth.tsx +++ b/lib/src/Components/Auth/useAuth.tsx @@ -1,5 +1,6 @@ -import { createContext, useState, useContext, useEffect } from 'react' +import { createContext, useState, useContext, useEffect, useCallback } from 'react' +import type { InviteApi } from '#types/InviteApi' import type { UserApi } from '#types/UserApi' import type { UserItem } from '#types/UserItem' @@ -8,6 +9,7 @@ export type { UserItem } from '#types/UserItem' interface AuthProviderProps { userApi: UserApi + inviteApi: InviteApi children?: React.ReactNode } @@ -46,21 +48,13 @@ const AuthContext = createContext({ /** * @category Auth */ -export const AuthProvider = ({ userApi, children }: AuthProviderProps) => { +export const AuthProvider = ({ userApi, inviteApi, children }: AuthProviderProps) => { const [user, setUser] = useState(null) const [token, setToken] = useState() - const [loading, setLoading] = useState(false) + const [loading, setLoading] = useState(true) const isAuthenticated = !!user - useEffect(() => { - setLoading(true) - // eslint-disable-next-line @typescript-eslint/no-floating-promises - loadUser() - setLoading(false) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) - - async function loadUser(): Promise { + const loadUser: () => Promise = useCallback(async () => { try { const token = await userApi.getToken() setToken(token) @@ -75,13 +69,23 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => { setLoading(false) return undefined } - } + }, [userApi]) + + useEffect(() => { + void loadUser() + }, [loadUser]) const login = async (credentials: AuthCredentials): Promise => { setLoading(true) try { const user = await userApi.login(credentials.email, credentials.password) setToken(user?.access_token) + const inviteCode = localStorage.getItem('inviteCode') + if (inviteCode) { + // If an invite code is stored, redeem it + await inviteApi.redeemInvite(inviteCode) + localStorage.removeItem('inviteCode') // Clear invite code after redeeming + } return await loadUser() } catch (error) { setLoading(false) diff --git a/lib/src/Components/Onboarding/InvitePage.tsx b/lib/src/Components/Onboarding/InvitePage.tsx new file mode 100644 index 00000000..a7104fa1 --- /dev/null +++ b/lib/src/Components/Onboarding/InvitePage.tsx @@ -0,0 +1,57 @@ +import { useEffect } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { toast } from 'react-toastify' + +import { useAuth } from '#components/Auth/useAuth' +import { MapOverlayPage } from '#components/Templates/MapOverlayPage' + +import type { InviteApi } from '#types/InviteApi' + +interface Props { + inviteApi: InviteApi +} + +/** + * @category Onboarding + */ +export function InvitePage({ inviteApi }: Props) { + const { isAuthenticated, loading: isLoadingAuthentication } = useAuth() + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + + if (!id) throw new Error('Invite ID is required') + + useEffect(() => { + async function redeemInvite() { + if (!id) throw new Error('Invite ID is required') + + const invitingProfileId = await inviteApi.redeemInvite(id) + if (invitingProfileId) { + toast.success('Invite redeemed successfully!') + navigate(`/item/${id}`) + } else { + toast.error('Failed to redeem invite') + } + navigate('/') + } + + if (isLoadingAuthentication) return + + if (isAuthenticated) { + void redeemInvite() + navigate('/') + } else { + // Save invite code in local storage + localStorage.setItem('inviteCode', id) + + // Redirect to login page + navigate('/login') + } + }, [id, isAuthenticated, inviteApi, navigate, isLoadingAuthentication]) + + return ( + +

Invitation

+
+ ) +} diff --git a/lib/src/Components/Onboarding/index.ts b/lib/src/Components/Onboarding/index.ts new file mode 100644 index 00000000..852a6f18 --- /dev/null +++ b/lib/src/Components/Onboarding/index.ts @@ -0,0 +1 @@ +export { InvitePage } from './InvitePage' diff --git a/lib/src/index.tsx b/lib/src/index.tsx index 498db190..19f8846f 100644 --- a/lib/src/index.tsx +++ b/lib/src/index.tsx @@ -8,6 +8,7 @@ export * from './Components/Gaming' export * from './Components/Templates' export * from './Components/Input' export * from './Components/Item' +export * from './Components/Onboarding' declare global { interface Window { diff --git a/lib/src/types/InviteApi.d.ts b/lib/src/types/InviteApi.d.ts new file mode 100644 index 00000000..b180a5ce --- /dev/null +++ b/lib/src/types/InviteApi.d.ts @@ -0,0 +1,4 @@ +export interface InviteApi { + validateInvite(inviteId: string): Promise + redeemInvite(inviteId: string): Promise +}