From 1e7320b8954ad292d04ecbfc15a7141bf72af9d5 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 11 Jul 2025 13:37:05 +0200 Subject: [PATCH] feat(app): qR invites (#267) * Add component to show invite link (WIP) * Show invite link with copy functionality and QR-Code, add tests * Query secrets * Update directus collections * Add config and invite api * Let vite resolve paths using tsconfig * Redeem invite link when logged in or after logging in * Redirect to inviting profile when redeeming * Fix some logic with login and redeeming * Use correct redeem flow * Hide missing form error * Add basic relations view * Pass profile to redeem Api and adapt to changed redeem flow * Remove unnecessary aliases in vite config * Remove dead import * gitignore mac specific file * Remove lazy loading * Fix linting * add InviteApi import * Change case of file name (tbd) * Don't toast error if user profile was not loaded yet * Fix casing * avoid app crash when profile of a new item is opened --------- Co-authored-by: Anton Tranelis --- app/.env | 5 +- app/.eslintrc.cjs | 2 +- app/.gitignore | 1 + app/package-lock.json | 50 +++++++++++- app/package.json | 1 + app/src/App.tsx | 17 ++-- app/src/api/directus.ts | 6 ++ app/src/api/inviteApi.ts | 79 +++++++++++++++++++ app/src/api/itemsApi.ts | 1 + app/src/api/userApi.ts | 4 +- app/src/config/index.ts | 12 +++ app/tsconfig.json | 42 +++++++--- app/vite.config.ts | 40 ++++------ lib/package-lock.json | 20 +++++ lib/package.json | 1 + lib/rollup.config.js | 1 + lib/src/Components/Auth/LoginPage.tsx | 49 ++++++++++-- lib/src/Components/Auth/useAuth.tsx | 34 +++++--- lib/src/Components/Map/hooks/useMyProfile.ts | 20 +++++ lib/src/Components/Onboarding/InvitePage.tsx | 75 ++++++++++++++++++ lib/src/Components/Onboarding/index.ts | 1 + .../Subcomponents/InviteLinkView.spec.tsx | 75 ++++++++++++++++++ .../Profile/Subcomponents/InviteLinkView.tsx | 38 +++++++++ .../Profile/Subcomponents/RelationsView.tsx | 37 +++++++++ .../InviteLinkView.spec.tsx.snap | 62 +++++++++++++++ .../Components/Profile/Templates/FlexForm.tsx | 1 + .../Components/Profile/Templates/FlexView.tsx | 4 + lib/src/index.tsx | 1 + lib/src/types/InviteApi.d.ts | 4 + lib/src/types/Item.d.ts | 5 ++ 30 files changed, 623 insertions(+), 65 deletions(-) create mode 100644 app/src/api/inviteApi.ts create mode 100644 app/src/config/index.ts create mode 100644 lib/src/Components/Map/hooks/useMyProfile.ts create mode 100644 lib/src/Components/Onboarding/InvitePage.tsx create mode 100644 lib/src/Components/Onboarding/index.ts create mode 100644 lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx create mode 100644 lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx create mode 100644 lib/src/Components/Profile/Subcomponents/RelationsView.tsx create mode 100644 lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap create mode 100644 lib/src/types/InviteApi.d.ts diff --git a/app/.env b/app/.env index cb285291..4d364d32 100644 --- a/app/.env +++ b/app/.env @@ -1 +1,4 @@ -VITE_OPEN_COLLECTIVE_API_KEY=your_key \ No newline at end of file +VITE_OPEN_COLLECTIVE_API_KEY=your_key +VITE_API_URL=https://api.utopia-lab.org +VITE_VALIDATE_INVITE_FLOW_ID=01d61db0-25aa-4bfa-bc24-c6a8f208a455 +VITE_REDEEM_INVITE_FLOW_ID=cc80ec73-ecf5-4789-bee5-1127fb1a6ed4 diff --git a/app/.eslintrc.cjs b/app/.eslintrc.cjs index 287f4b5d..af43f19b 100644 --- a/app/.eslintrc.cjs +++ b/app/.eslintrc.cjs @@ -82,7 +82,7 @@ module.exports = { 'import/no-relative-parent-imports': [ 'error', { - ignore: ['#[src,types,root,components,utils,assets]/*'], + ignore: ['#[src,types,root,components,utils,assets]/*', '@/config/*'], }, ], 'import/no-self-import': 'error', diff --git a/app/.gitignore b/app/.gitignore index b9470778..3bdd52eb 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +.DS_Store diff --git a/app/package-lock.json b/app/package-lock.json index f6897356..7bc582bd 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -18,7 +18,8 @@ "react-dom": "^18.2.0", "react-rnd": "^10.4.1", "react-router-dom": "^6.23.0", - "utopia-ui": "^3.0.111" + "utopia-ui": "^3.0.111", + "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.4.1", @@ -6740,6 +6741,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -10984,6 +10991,26 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -11147,7 +11174,7 @@ "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -11621,6 +11648,25 @@ } } }, + "node_modules/vite-tsconfig-paths": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", + "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, "node_modules/vite/node_modules/fdir": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", diff --git a/app/package.json b/app/package.json index 1f1ca1d3..1c6e9e38 100644 --- a/app/package.json +++ b/app/package.json @@ -20,6 +20,7 @@ "react-dom": "^18.2.0", "react-rnd": "^10.4.1", "react-router-dom": "^6.23.0", + "vite-tsconfig-paths": "^5.1.4", "utopia-ui": "^3.0.111" }, "devDependencies": { diff --git a/app/src/App.tsx b/app/src/App.tsx index 9b789c5e..39b07772 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -20,6 +20,7 @@ import { Content, AuthProvider, Modal, + InvitePage, LoginPage, SignupPage, Quests, @@ -33,8 +34,8 @@ import { MarketView, SVG, LoadingMapOverlay, - ProfileView, ProfileForm, + ProfileView, UserSettings, } from 'utopia-ui' @@ -48,11 +49,16 @@ import { itemsApi } from './api/itemsApi' import { layersApi } from './api/layersApi' import { mapApi } from './api/mapApi' import { permissionsApi } from './api/permissionsApi' -import { userApi } from './api/userApi' +import { UserApi } from './api/userApi' import { ModalContent } from './ModalContent' import { Landingpage } from './pages/Landingpage' import MapContainer from './pages/MapContainer' import { getBottomRoutes, routes } from './routes/sidebar' +import { config } from '@/config' +import { InviteApi } from './api/inviteApi' + +const userApi = new UserApi() +const inviteApi = new InviteApi(userApi) function App() { const [permissionsApiInstance, setPermissionsApiInstance] = useState() @@ -140,12 +146,12 @@ function App() { if (map && layers) return (
- + }> - } /> + } /> + } /> } /> { + try { + const response = await fetch( + `${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}?secret=${inviteId}`, + { + method: 'GET', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + + 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) + if (error instanceof Error && error.message) { + throw new Error(error.message) + } else { + throw new Error('An unknown error occurred while fetching the inviting profile.') + } + } + } + + async redeemInvite(inviteId: string, itemId: string): Promise { + try { + const token = await this.userApi.getToken() + + if (!token) { + throw new Error('User is not authenticated. Cannot redeem invite.') + } + + const response = await fetch(`${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}`, { + method: 'POST', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ secret: inviteId, item: itemId }), + }) + + if (!response.ok) return null + + return (await response.json()) as string + } catch (error: unknown) { + // eslint-disable-next-line no-console + console.error('Error fetching inviting profile:', error) + if (error instanceof Error && error.message) { + throw new Error(error.message) + } else { + throw new Error('An unknown error occurred while fetching the inviting profile.') + } + } + } +} diff --git a/app/src/api/itemsApi.ts b/app/src/api/itemsApi.ts index 854734ab..defb7c99 100644 --- a/app/src/api/itemsApi.ts +++ b/app/src/api/itemsApi.ts @@ -45,6 +45,7 @@ export class itemsApi implements ItemsApi { readItems(this.collectionName as never, { fields: [ '*', + 'secrets.*', 'to.*', 'relations.*', 'user_created.*', diff --git a/app/src/api/userApi.ts b/app/src/api/userApi.ts index 8876e02f..c63e1baa 100644 --- a/app/src/api/userApi.ts +++ b/app/src/api/userApi.ts @@ -8,7 +8,7 @@ import { createUser, passwordRequest, passwordReset, readMe, updateMe } from '@d import { directusClient } from './directus' -import type { UserApi, UserItem } from 'utopia-ui' +import type { UserItem } from 'utopia-ui' interface DirectusError { errors: { @@ -17,7 +17,7 @@ interface DirectusError { }[] } -export class userApi implements UserApi { +export class UserApi { async register(email: string, password: string, userName: string): Promise { try { return await directusClient.request(createUser({ email, password, first_name: userName })) diff --git a/app/src/config/index.ts b/app/src/config/index.ts new file mode 100644 index 00000000..28ab494c --- /dev/null +++ b/app/src/config/index.ts @@ -0,0 +1,12 @@ +export const config = { + apiUrl: String(import.meta.env.VITE_API_URL ?? 'https://api.utopia-lab.org'), + validateInviteFlowId: String( + import.meta.env.VITE_VALIDATE_INVITE_FLOW_ID ?? '01d61db0-25aa-4bfa-bc24-c6a8f208a455', + ), + redeemInviteFlowId: String( + import.meta.env.VITE_REDEEM_INVITE_FLOW_ID ?? 'cc80ec73-ecf5-4789-bee5-1127fb1a6ed4', + ), + openCollectiveApiKey: String(import.meta.env.VITE_OPEN_COLLECTIVE_API_KEY ?? ''), +} + +export type Config = typeof config diff --git a/app/tsconfig.json b/app/tsconfig.json index 5469587c..95a0cf49 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -10,16 +10,40 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, + "baseUrl": ".", "paths": { - "utopia-ui": ["../lib/src"], - "#components/*": ["../lib/src/Components/*"], - "#utils/*": ["../lib/src/Utils/*"], - "#types/*": ["../lib/src/types/*"], - "#assets/*": ["../lib/src/assets/*"], - "#src/*": ["../lib/src/*"], - "#root/*": ["../lib/*"] + "@/*": [ + "src/*" + ], + "utopia-ui": [ + "../lib/src" + ], + "#components/*": [ + "../lib/src/Components/*" + ], + "#utils/*": [ + "../lib/src/Utils/*" + ], + "#types/*": [ + "../lib/src/types/*" + ], + "#assets/*": [ + "../lib/src/assets/*" + ], + "#src/*": [ + "../lib/src/*" + ], + "#root/*": [ + "../lib/*" + ] } }, - "include": ["src"], - "references": [{ "path": "./tsconfig.node.json" }] + "include": [ + "src" + ], + "references": [ + { + "path": "./tsconfig.node.json" + } + ] } diff --git a/app/vite.config.ts b/app/vite.config.ts index 0bb7f880..2be6ed48 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -1,11 +1,12 @@ -import { defineConfig } from 'vite'; -import react from '@vitejs/plugin-react'; -import tailwindcss from '@tailwindcss/vite'; -import fs from 'fs'; -import path from 'path'; +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import fs from 'fs' +import path from 'path' +import tsConfigPaths from 'vite-tsconfig-paths' // __dirname-Ersatz für ESModules -const __dirname = path.dirname(new URL(import.meta.url).pathname); +const __dirname = path.dirname(new URL(import.meta.url).pathname) export default defineConfig({ server: { @@ -18,21 +19,9 @@ export default defineConfig({ * }, */ }, - plugins: [ - react(), - tailwindcss(), - ], + plugins: [react(), tailwindcss(), tsConfigPaths()], resolve: { dedupe: ['react', 'react-dom', 'react-router-dom'], - alias: { - 'utopia-ui': path.resolve(__dirname, '../lib/src'), - '#components': path.resolve(__dirname, '../lib/src/Components'), - '#utils': path.resolve(__dirname, '../lib/src/Utils'), - '#types': path.resolve(__dirname, '../lib/src/types'), - '#assets': path.resolve(__dirname, '../lib/src/assets'), - '#src': path.resolve(__dirname, '../lib/src'), - '#root': path.resolve(__dirname, '../lib'), - } }, build: { sourcemap: true, @@ -44,21 +33,20 @@ export default defineConfig({ } if (id.includes('node_modules')) { if (id.includes('react')) { - return 'react'; + return 'react' } if (id.includes('tiptap')) { - return 'tiptap'; + return 'tiptap' } if (id.includes('leaflet')) { - return 'leaflet'; + return 'leaflet' } if (id.includes('lib/node_modules')) { return 'utopia-ui-vendor' - } - else return 'vendor'; + } else return 'vendor' } - } + }, }, }, }, -}); +}) diff --git a/lib/package-lock.json b/lib/package-lock.json index d6cd9828..4dbba735 100644 --- a/lib/package-lock.json +++ b/lib/package-lock.json @@ -37,6 +37,7 @@ "react-leaflet-cluster": "^2.1.0", "react-markdown": "^9.0.1", "react-photo-album": "^3.0.2", + "react-qr-code": "^2.0.16", "react-router-dom": "^6.23.0", "react-toastify": "^9.1.3", "remark-breaks": "^4.0.0", @@ -11103,6 +11104,12 @@ "node": ">=6" } }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==", + "license": "MIT" + }, "node_modules/qs": { "version": "6.13.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz", @@ -11319,6 +11326,19 @@ } } }, + "node_modules/react-qr-code": { + "version": "2.0.16", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.16.tgz", + "integrity": "sha512-8f54aTOo7DxYr1LB47pMeclV5SL/zSbJxkXHIS2a+QnAIa4XDVIdmzYRC+CBCJeDLSCeFHn8gHtltwvwZGJD/w==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", diff --git a/lib/package.json b/lib/package.json index 0601b494..0a1fe6bc 100644 --- a/lib/package.json +++ b/lib/package.json @@ -125,6 +125,7 @@ "react-leaflet-cluster": "^2.1.0", "react-markdown": "^9.0.1", "react-photo-album": "^3.0.2", + "react-qr-code": "^2.0.16", "react-router-dom": "^6.23.0", "react-toastify": "^9.1.3", "remark-breaks": "^4.0.0", diff --git a/lib/rollup.config.js b/lib/rollup.config.js index a68ba830..25e90725 100644 --- a/lib/rollup.config.js +++ b/lib/rollup.config.js @@ -47,6 +47,7 @@ export default [ /node_modules\/tiptap-markdown/, /node_modules\/markdown-it-task-lists/, /node_modules\/classnames/, + /node_modules\/react-qr-code/, ], requireReturnsDefault: 'auto', }), diff --git a/lib/src/Components/Auth/LoginPage.tsx b/lib/src/Components/Auth/LoginPage.tsx index b3a835a1..a87bdd01 100644 --- a/lib/src/Components/Auth/LoginPage.tsx +++ b/lib/src/Components/Auth/LoginPage.tsx @@ -1,28 +1,65 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { Link, useNavigate } from 'react-router-dom' import { toast } from 'react-toastify' +import { useMyProfile } from '#components/Map/hooks/useMyProfile' import { MapOverlayPage } from '#components/Templates/MapOverlayPage' import { useAuth } from './useAuth' +import type { InviteApi } from '#types/InviteApi' + +interface Props { + inviteApi: InviteApi +} + /** * @category Auth */ -export function LoginPage() { +export function LoginPage({ inviteApi }: Props) { const [email, setEmail] = useState('') const [password, setPassword] = useState('') const { login, loading } = useAuth() + const { myProfile, isMyProfileLoaded } = useMyProfile() + const navigate = useNavigate() - // eslint-disable-next-line react-hooks/exhaustive-deps - const onLogin = async () => { + const redeemInvite = useCallback( + async (inviteCode: string): Promise => { + if (!isMyProfileLoaded) return null + + if (!myProfile) { + toast.error('Could not find your profile to redeem the invite.') + return null + } + + const invitingProfileId = await inviteApi.redeemInvite(inviteCode, myProfile.id) + localStorage.removeItem('inviteCode') // Clear invite code after redeeming + return invitingProfileId + }, + [inviteApi, isMyProfileLoaded, myProfile], + ) + + const handleSuccess = useCallback(async () => { + const inviteCode = localStorage.getItem('inviteCode') + let invitingProfileId: string | null = null + if (inviteCode) { + invitingProfileId = await redeemInvite(inviteCode) + } + if (invitingProfileId) { + navigate(`/item/${invitingProfileId}`) + } else { + navigate('/') + } + }, [navigate, redeemInvite]) + + const onLogin = useCallback(async () => { await toast.promise(login({ email, password }), { success: { render({ data }) { - navigate('/') + void handleSuccess() return `Hi ${data?.first_name ? data.first_name : 'Traveler'}` }, // other options @@ -36,7 +73,7 @@ export function LoginPage() { }, pending: 'logging in ...', }) - } + }, [email, handleSuccess, login, password]) useEffect(() => { const keyDownHandler = (event: KeyboardEvent) => { diff --git a/lib/src/Components/Auth/useAuth.tsx b/lib/src/Components/Auth/useAuth.tsx index 83c416b0..413e88f5 100644 --- a/lib/src/Components/Auth/useAuth.tsx +++ b/lib/src/Components/Auth/useAuth.tsx @@ -1,10 +1,12 @@ -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' interface AuthProviderProps { userApi: UserApi + inviteApi: InviteApi children?: React.ReactNode } @@ -16,6 +18,7 @@ interface AuthCredentials { interface AuthContextProps { isAuthenticated: boolean + isInitialized: boolean user: UserItem | null login: (credentials: AuthCredentials) => Promise register: (credentials: AuthCredentials, userName: string) => Promise @@ -29,6 +32,7 @@ interface AuthContextProps { const AuthContext = createContext({ isAuthenticated: false, + isInitialized: false, user: null, login: () => Promise.reject(Error('Unimplemented')), register: () => Promise.reject(Error('Unimplemented')), @@ -47,17 +51,10 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => { const [user, setUser] = useState(null) const [token, setToken] = useState() const [loading, setLoading] = useState(false) + const [isInitialized, setIsInitialized] = useState(false) 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) @@ -66,20 +63,30 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => { setUser(me) setLoading(false) return me - } else return undefined + } else { + return undefined + } // eslint-disable-next-line no-catch-all/no-catch-all } catch (error) { setLoading(false) return undefined + } finally { + setIsInitialized(true) } - } + }, [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) - return await loadUser() + const fullUser = await loadUser() + + return fullUser } catch (error) { setLoading(false) throw error @@ -150,6 +157,7 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => { { + const items = useItems() + const allItemsLoaded = useAllItemsLoaded() + + const user = useAuth().user + + // allItemsLoaded is not reliable, so we check if items.length > 0 + const isMyProfileLoaded = allItemsLoaded && items.length > 0 && !!user + + // Find the user's profile item + const myProfile = items.find( + (item) => item.layer?.userProfileLayer && item.user_created?.id === user?.id, + ) + + return { myProfile, isMyProfileLoaded } +} diff --git a/lib/src/Components/Onboarding/InvitePage.tsx b/lib/src/Components/Onboarding/InvitePage.tsx new file mode 100644 index 00000000..800766a8 --- /dev/null +++ b/lib/src/Components/Onboarding/InvitePage.tsx @@ -0,0 +1,75 @@ +import { useEffect } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import { toast } from 'react-toastify' + +import { useAuth } from '#components/Auth/useAuth' +import { useMyProfile } from '#components/Map/hooks/useMyProfile' +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, isInitialized: isAuthenticationInitialized } = useAuth() + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + + const { myProfile, isMyProfileLoaded } = useMyProfile() + + if (!id) throw new Error('Invite ID is required') + + useEffect(() => { + async function redeemInvite() { + if (!id) throw new Error('Invite ID is required') + + if (!isMyProfileLoaded) return + + if (!myProfile) { + toast.error('Could not find your profile to redeem the invite.') + return + } + + const invitingProfileId = await inviteApi.redeemInvite(id, myProfile.id) + + if (invitingProfileId) { + toast.success('Invite redeemed successfully!') + navigate(`/item/${invitingProfileId}`) + } else { + toast.error('Failed to redeem invite') + navigate('/') + } + } + + if (!isAuthenticationInitialized) return + + if (isAuthenticated) { + void redeemInvite() + } else { + // Save invite code in local storage + localStorage.setItem('inviteCode', id) + + // Redirect to login page + navigate('/login') + } + }, [ + id, + isAuthenticated, + inviteApi, + navigate, + isAuthenticationInitialized, + myProfile, + isMyProfileLoaded, + ]) + + 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/Components/Profile/Subcomponents/InviteLinkView.spec.tsx b/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx new file mode 100644 index 00000000..ece8b690 --- /dev/null +++ b/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx @@ -0,0 +1,75 @@ +import { render, fireEvent } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' + +import { InviteLinkView } from './InviteLinkView' + +import type { Item } from '#types/Item' + +const itemWithSecret: Item = { + secrets: [ + { + secret: 'secret1', + }, + ], + id: '1', + name: 'Test Item', +} + +const itemWithoutSecret: Item = { + secrets: [], + id: '2', + name: 'Test Item Without Secret', +} + +const itemWithUndefinedSecrets: Item = { + id: '3', + name: 'Test Item With Undefined Secrets', +} + +describe('', () => { + let wrapper: ReturnType + + const Wrapper = ({ item }: { item: Item }) => { + return render() + } + + describe('when item does not have secrets', () => { + it('does not render anything', () => { + wrapper = Wrapper({ item: itemWithoutSecret }) + expect(wrapper.container.firstChild).toBeNull() + }) + }) + + describe('when item has secrets undefined', () => { + it('does not render anything', () => { + wrapper = Wrapper({ item: itemWithUndefinedSecrets }) + expect(wrapper.container.firstChild).toBeNull() + }) + }) + + describe('when item has secrets', () => { + beforeEach(() => { + wrapper = Wrapper({ item: itemWithSecret }) + }) + + it('renders the secret', () => { + expect(wrapper.getByDisplayValue('secret1', { exact: false })).toBeInTheDocument() + }) + + it('matches the snapshot', () => { + expect(wrapper.container.firstChild).toMatchSnapshot() + }) + + it('copies the secret to clipboard when button is clicked', () => { + const copyButton = wrapper.getByRole('button') + expect(copyButton).toBeInTheDocument() + + const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText') + + fireEvent.click(copyButton) + + // TODO Implement in a way that the URL stays consistent on CI + expect(clipboardSpy).toHaveBeenCalledWith('http://localhost:3000/invite/secret1') + }) + }) +}) diff --git a/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx b/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx new file mode 100644 index 00000000..34bf615b --- /dev/null +++ b/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx @@ -0,0 +1,38 @@ +import { ClipboardIcon } from '@heroicons/react/24/outline' +import QRCode from 'react-qr-code' +import { toast } from 'react-toastify' + +import type { Item } from '#types/Item' + +export const InviteLinkView = ({ item }: { item: Item }) => { + // Only show if user has permission to view secrets. + if (!item.secrets || item.secrets.length === 0) return + + const link = `${window.location.origin}/invite/${item.secrets[0].secret}` + + const copyToClipboard = () => { + void navigator.clipboard + .writeText(link) + .then(() => toast.success('Invite link copied to clipboard!')) + } + + return ( +
+

Invite

+
+ + +
+
+ +
+
+ ) +} diff --git a/lib/src/Components/Profile/Subcomponents/RelationsView.tsx b/lib/src/Components/Profile/Subcomponents/RelationsView.tsx new file mode 100644 index 00000000..2348b646 --- /dev/null +++ b/lib/src/Components/Profile/Subcomponents/RelationsView.tsx @@ -0,0 +1,37 @@ +import { useItems } from '#components/Map/hooks/useItems' + +import type { Item } from '#types/Item' + +interface Props { + item: Item + relation: string +} + +export const RelationsView = ({ item, relation }: Props) => { + const items = useItems() + + if (!item.relations) return + + const relationsOfRightType = item.relations.filter((r) => r.type === relation) + + const relatedItems = items.filter((i) => relationsOfRightType.some((r) => r.id === i.id)) + + const hasRelatedItems = relatedItems.length > 0 + + return ( +
+

{relation}

+ {hasRelatedItems ? ( + + ) : ( +

No related items found.

+ )} +
+ ) +} diff --git a/lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap b/lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap new file mode 100644 index 00000000..0340551f --- /dev/null +++ b/lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap @@ -0,0 +1,62 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > when item has secrets > matches the snapshot 1`] = ` +
+

+ Invite +

+
+ + +
+
+ + + + +
+
+`; diff --git a/lib/src/Components/Profile/Templates/FlexForm.tsx b/lib/src/Components/Profile/Templates/FlexForm.tsx index 87a5dca1..59406070 100644 --- a/lib/src/Components/Profile/Templates/FlexForm.tsx +++ b/lib/src/Components/Profile/Templates/FlexForm.tsx @@ -17,6 +17,7 @@ const componentMap = { startEnd: ProfileStartEndForm, crowdfundings: CrowdfundingForm, gallery: GalleryForm, + inviteLinks: () => null, // Not needed for now // weitere Komponenten hier } diff --git a/lib/src/Components/Profile/Templates/FlexView.tsx b/lib/src/Components/Profile/Templates/FlexView.tsx index 845898c3..e3024c45 100644 --- a/lib/src/Components/Profile/Templates/FlexView.tsx +++ b/lib/src/Components/Profile/Templates/FlexView.tsx @@ -4,8 +4,10 @@ import { ContactInfoView } from '#components/Profile/Subcomponents/ContactInfoVi import { CrowdfundingView } from '#components/Profile/Subcomponents/CrowdfundingView' import { GalleryView } from '#components/Profile/Subcomponents/GalleryView' import { GroupSubHeaderView } from '#components/Profile/Subcomponents/GroupSubHeaderView' +import { InviteLinkView } from '#components/Profile/Subcomponents/InviteLinkView' import { ProfileStartEndView } from '#components/Profile/Subcomponents/ProfileStartEndView' import { ProfileTextView } from '#components/Profile/Subcomponents/ProfileTextView' +import { RelationsView } from '#components/Profile/Subcomponents/RelationsView' import type { Item } from '#types/Item' import type { Key } from 'react' @@ -17,6 +19,8 @@ const componentMap = { startEnd: ProfileStartEndView, gallery: GalleryView, crowdfundings: CrowdfundingView, + inviteLinks: InviteLinkView, + relations: RelationsView, // weitere Komponenten hier } diff --git a/lib/src/index.tsx b/lib/src/index.tsx index 8a9d5e89..f3239c11 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' export * from './Components/Profile' declare global { diff --git a/lib/src/types/InviteApi.d.ts b/lib/src/types/InviteApi.d.ts new file mode 100644 index 00000000..a4a335c6 --- /dev/null +++ b/lib/src/types/InviteApi.d.ts @@ -0,0 +1,4 @@ +export interface InviteApi { + validateInvite(inviteId: string): Promise + redeemInvite(inviteId: string, itemId: string): Promise +} diff --git a/lib/src/types/Item.d.ts b/lib/src/types/Item.d.ts index 7dd9dcf1..12d4b8e2 100644 --- a/lib/src/types/Item.d.ts +++ b/lib/src/types/Item.d.ts @@ -18,6 +18,10 @@ interface GalleryItem { | string } +interface ItemSecret { + secret: string +} + /** * @category Types */ @@ -55,6 +59,7 @@ export interface Item { next_appointment?: string gallery?: GalleryItem[] openCollectiveSlug?: string + secrets?: ItemSecret[] // { // coordinates: [number, number]