diff --git a/app/.env b/app/.env index cb2852912..4d364d320 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 287f4b5d7..af43f19b5 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 b94707787..3bdd52eb2 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 f68973565..7bc582bdd 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 1f1ca1d31..1c6e9e380 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 9b789c5eb..39b077724 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 854734abd..defb7c995 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 8876e02fd..c63e1baa1 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 000000000..28ab494c3 --- /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 5469587c7..95a0cf499 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 0bb7f8802..2be6ed483 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 d6cd9828b..4dbba7355 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 0601b494e..0a1fe6bcd 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 a68ba8306..25e907259 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/AppShell/ContextWrapper.tsx b/lib/src/Components/AppShell/ContextWrapper.tsx index c4245b52e..93fc84298 100644 --- a/lib/src/Components/AppShell/ContextWrapper.tsx +++ b/lib/src/Components/AppShell/ContextWrapper.tsx @@ -7,7 +7,6 @@ import { QuestsProvider } from '#components/Gaming/hooks/useQuests' import { ClusterRefProvider } from '#components/Map/hooks/useClusterRef' import { FilterProvider } from '#components/Map/hooks/useFilter' import { ItemsProvider } from '#components/Map/hooks/useItems' -import { LayersProvider } from '#components/Map/hooks/useLayers' import { LeafletRefsProvider } from '#components/Map/hooks/useLeafletRefs' import { PermissionsProvider } from '#components/Map/hooks/usePermissions' import { PopupFormProvider } from '#components/Map/hooks/usePopupForm' @@ -59,40 +58,38 @@ export const Wrappers = ({ children }) => { return ( - + - - - - - - - - - - {children} - - - - - - - - + + + + + + + + + {children} + + + + + + + - + ) diff --git a/lib/src/Components/Auth/LoginPage.tsx b/lib/src/Components/Auth/LoginPage.tsx index b3a835a13..a87bdd01b 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 83c416b08..413e88f58 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 leafletRefs = useLeafletRefs() const allTagsLoaded = useAllTagsLoaded() - const allItemsLoaded = useAllItemsLoaded() + + const allVisibleLayersInitialized = useAllVisibleLayersInitialized() const setMarkerClicked = useSetMarkerClicked() const selectPosition = useSelectPosition() @@ -103,7 +105,7 @@ export const PopupView = ({ children }: { children?: React.ReactNode }) => { }) } - if (allTagsLoaded && allItemsLoaded) { + if (allTagsLoaded && allVisibleLayersInitialized) { item.text?.match(hashTagRegex)?.map((tag) => { if ( !tags.find((t) => t.name.toLocaleLowerCase() === tag.slice(1).toLocaleLowerCase()) && diff --git a/lib/src/Components/Map/Layer.tsx b/lib/src/Components/Map/Layer.tsx index 6d54af718..5ae163c4a 100644 --- a/lib/src/Components/Map/Layer.tsx +++ b/lib/src/Components/Map/Layer.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useSetItemsApi, useSetItemsData } from './hooks/useItems' import { useAddTag } from './hooks/useTags' @@ -43,52 +43,98 @@ export const Layer = ({ const [newTagsToAdd] = useState([]) const [tagsReady] = useState(false) + const initializeWithData = useCallback(() => { + if (!data) return + setItemsData({ + data, + children, + name, + menuIcon, + menuText, + menuColor, + markerIcon, + markerShape, + markerDefaultColor, + markerDefaultColor2, + api, + itemType, + userProfileLayer, + customEditLink, + customEditParameter, + // eslint-disable-next-line camelcase + public_edit_items, + listed, + }) + }, [ + api, + children, + customEditLink, + customEditParameter, + data, + itemType, + listed, + markerDefaultColor, + markerDefaultColor2, + markerIcon, + markerShape, + menuColor, + menuIcon, + menuText, + name, + // eslint-disable-next-line camelcase + public_edit_items, + setItemsData, + userProfileLayer, + ]) + + const initializeWithApi = useCallback(() => { + if (!api) return + setItemsApi({ + data, + children, + name, + menuIcon, + menuText, + menuColor, + markerIcon, + markerShape, + markerDefaultColor, + markerDefaultColor2, + api, + itemType, + userProfileLayer, + customEditLink, + customEditParameter, + // eslint-disable-next-line camelcase + public_edit_items, + listed, + }) + }, [ + api, + children, + customEditLink, + customEditParameter, + data, + itemType, + listed, + markerDefaultColor, + markerDefaultColor2, + markerIcon, + markerShape, + menuColor, + menuIcon, + menuText, + name, + // eslint-disable-next-line camelcase + public_edit_items, + setItemsApi, + userProfileLayer, + ]) + useEffect(() => { - data && - setItemsData({ - data, - children, - name, - menuIcon, - menuText, - menuColor, - markerIcon, - markerShape, - markerDefaultColor, - markerDefaultColor2, - api, - itemType, - userProfileLayer, - // Can we just use editCallback for all cases? - customEditLink, - customEditParameter, - // eslint-disable-next-line camelcase - public_edit_items, - listed, - }) - api && - setItemsApi({ - data, - children, - name, - menuIcon, - menuText, - menuColor, - markerIcon, - markerShape, - markerDefaultColor, - markerDefaultColor2, - api, - itemType, - userProfileLayer, - customEditLink, - customEditParameter, - // eslint-disable-next-line camelcase - public_edit_items, - listed, - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [data, api]) + if (data) initializeWithData() + if (api) initializeWithApi() + }, [data, api, initializeWithData, initializeWithApi]) useEffect(() => { if (tagsReady) { diff --git a/lib/src/Components/Map/Subcomponents/AddButton.tsx b/lib/src/Components/Map/Subcomponents/AddButton.tsx index 5781a1ab5..c6277b47d 100644 --- a/lib/src/Components/Map/Subcomponents/AddButton.tsx +++ b/lib/src/Components/Map/Subcomponents/AddButton.tsx @@ -3,7 +3,7 @@ import SVG from 'react-inlinesvg' import PlusSVG from '#assets/plus.svg' -import { useLayers } from '#components/Map/hooks/useLayers' +import { useLayers } from '#components/Map/hooks/useItems' import { useHasUserPermission } from '#components/Map/hooks/usePermissions' export default function AddButton({ diff --git a/lib/src/Components/Map/Subcomponents/Controls/LayerControl.tsx b/lib/src/Components/Map/Subcomponents/Controls/LayerControl.tsx index 10180de06..016678e30 100644 --- a/lib/src/Components/Map/Subcomponents/Controls/LayerControl.tsx +++ b/lib/src/Components/Map/Subcomponents/Controls/LayerControl.tsx @@ -3,7 +3,7 @@ import SVG from 'react-inlinesvg' import LayerSVG from '#assets/layer.svg' import { useIsLayerVisible, useToggleVisibleLayer } from '#components/Map/hooks/useFilter' -import { useLayers } from '#components/Map/hooks/useLayers' +import { useLayers } from '#components/Map/hooks/useItems' export function LayerControl({ expandLayerControl = false }: { expandLayerControl: boolean }) { const [open, setOpen] = useState(expandLayerControl) diff --git a/lib/src/Components/Map/UtopiaMapInner.tsx b/lib/src/Components/Map/UtopiaMapInner.tsx index d197f92a6..68a491771 100644 --- a/lib/src/Components/Map/UtopiaMapInner.tsx +++ b/lib/src/Components/Map/UtopiaMapInner.tsx @@ -24,7 +24,7 @@ import { useResetFilterTags, useToggleVisibleLayer, } from './hooks/useFilter' -import { useLayers } from './hooks/useLayers' +import { useLayers } from './hooks/useItems' import { useLeafletRefs } from './hooks/useLeafletRefs' import { usePopupForm } from './hooks/usePopupForm' import { @@ -44,6 +44,7 @@ import { TextView } from './Subcomponents/ItemPopupComponents/TextView' import { SelectPosition } from './Subcomponents/SelectPosition' import type { Feature, Geometry as GeoJSONGeometry, GeoJsonObject } from 'geojson' +import { LayerProps } from '#types/LayerProps' export function UtopiaMapInner({ children, @@ -85,9 +86,8 @@ export function UtopiaMapInner({ useTheme(defaultTheme) useEffect(() => { - layers.forEach((layer) => addVisibleLayer(layer)) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [layers]) + layers.forEach((layer: LayerProps) => addVisibleLayer(layer)) + }, [addVisibleLayer, layers]) const setAppState = useSetAppState() diff --git a/lib/src/Components/Map/hooks/useFilter.tsx b/lib/src/Components/Map/hooks/useFilter.tsx index d7733f04d..0bfa8a94c 100644 --- a/lib/src/Components/Map/hooks/useFilter.tsx +++ b/lib/src/Components/Map/hooks/useFilter.tsx @@ -7,7 +7,7 @@ import { useCallback, useReducer, createContext, useContext, useState, useEffect } from 'react' import { useNavigate } from 'react-router-dom' -import { useLayers } from './useLayers' +import { useLayers, useLayerState } from './useItems' import useWindowDimensions from './useWindowDimension' import type { LayerProps } from '#types/LayerProps' @@ -350,3 +350,12 @@ export const useVisibleGroupType = (): UseFilterManagerResult['visibleGroupTypes const { visibleGroupTypes } = useContext(FilterContext) return visibleGroupTypes } + +export const useAllVisibleLayersInitialized = (): boolean => { + const { visibleLayers } = useContext(FilterContext) + const layers = useLayerState() + return visibleLayers.every((layer) => { + const foundLayer = layers.find((l) => l.props.name === layer.name) + return foundLayer ? foundLayer.isInitialized : false + }) +} diff --git a/lib/src/Components/Map/hooks/useItems.tsx b/lib/src/Components/Map/hooks/useItems.tsx index 0fb1af787..13f8126a7 100644 --- a/lib/src/Components/Map/hooks/useItems.tsx +++ b/lib/src/Components/Map/hooks/useItems.tsx @@ -5,15 +5,24 @@ /* eslint-disable @typescript-eslint/restrict-template-expressions */ /* eslint-disable @typescript-eslint/no-misused-promises */ -import { useCallback, useReducer, createContext, useContext, useState } from 'react' +import { useCallback, useReducer, createContext, useContext } from 'react' import { toast } from 'react-toastify' -import { useAddLayer } from './useLayers' - import type { Item } from '#types/Item' import type { LayerProps } from '#types/LayerProps' +type LayerState = { + props: LayerProps + isInitialized: boolean +}[] + +interface State { + layers: LayerState + items: Item[] +} + type ActionType = + | { type: 'ADD_LAYER'; layer: LayerProps; items: Item[] } | { type: 'ADD'; item: Item } | { type: 'UPDATE'; item: Item } | { type: 'REMOVE'; item: Item } @@ -22,6 +31,7 @@ type ActionType = type UseItemManagerResult = ReturnType const ItemContext = createContext({ + layers: [], items: [], addItem: () => {}, updateItem: () => {}, @@ -29,10 +39,13 @@ const ItemContext = createContext({ resetItems: () => {}, setItemsApi: () => {}, setItemsData: () => {}, - allItemsLoaded: false, }) -function useItemsManager(initialItems: Item[]): { +function useItemsManager( + initialItems: Item[], + initialLayers: LayerState, +): { + layers: LayerState items: Item[] addItem: (item: Item) => void updateItem: (item: Item) => void @@ -40,39 +53,62 @@ function useItemsManager(initialItems: Item[]): { resetItems: (layer: LayerProps) => void setItemsApi: (layer: LayerProps) => void setItemsData: (layer: LayerProps) => void - allItemsLoaded: boolean } { - const addLayer = useAddLayer() - - const [allItemsLoaded, setallItemsLoaded] = useState(false) - - const [items, dispatch] = useReducer((state: Item[], action: ActionType) => { - switch (action.type) { - case 'ADD': - // eslint-disable-next-line no-case-declarations - const exist = state.find((item) => item.id === action.item.id) - if (!exist) { - return [...state, action.item] - } else return state - case 'UPDATE': - return state.map((item) => { - if (item.id === action.item.id) { - return action.item + const [{ items, layers }, dispatch] = useReducer( + (state: State, action: ActionType) => { + switch (action.type) { + case 'ADD_LAYER': + return { + layers: [ + ...state.layers, + { + props: action.layer, + isInitialized: true, + }, + ], + items: [ + ...state.items, + ...action.items.map((item) => ({ ...item, layer: action.layer })), + ], + } + case 'ADD': + // eslint-disable-next-line no-case-declarations + const exist = state.items.find((item) => item.id === action.item.id) + if (!exist) { + return { + ...state, + items: [...state.items, action.item], + } + } else return state + case 'UPDATE': + return { + ...state, + items: state.items.map((item) => { + if (item.id === action.item.id) { + return action.item + } + return item + }), } - return item - }) - case 'REMOVE': - return state.filter((item) => item !== action.item) - case 'RESET': - return state.filter((item) => item.layer?.name !== action.layer.name) - default: - throw new Error() - } - }, initialItems) + case 'REMOVE': + return { + ...state, + items: state.items.filter((item) => item !== action.item), + } + case 'RESET': + return { + ...state, + items: state.items.filter((item) => item.layer?.name !== action.layer.name), + } + default: + throw new Error() + } + }, + { items: initialItems, layers: initialLayers } as State, + ) const setItemsApi = useCallback(async (layer: LayerProps) => { - addLayer(layer) - const result = await toast.promise(layer.api!.getItems(), { + const items = await toast.promise(layer.api!.getItems(), { pending: `loading ${layer.name} ...`, success: `${layer.name} loaded`, error: { @@ -81,22 +117,12 @@ function useItemsManager(initialItems: Item[]): { }, }, }) - result.map((item) => { - dispatch({ type: 'ADD', item: { ...item, layer } }) - return null - }) - setallItemsLoaded(true) - // eslint-disable-next-line react-hooks/exhaustive-deps + dispatch({ type: 'ADD_LAYER', layer, items }) }, []) const setItemsData = useCallback((layer: LayerProps) => { - addLayer(layer) - layer.data?.map((item) => { - dispatch({ type: 'ADD', item: { ...item, layer } }) - return null - }) - setallItemsLoaded(true) - // eslint-disable-next-line react-hooks/exhaustive-deps + if (!layer.data) return + dispatch({ type: 'ADD_LAYER', layer, items: layer.data }) }, []) const addItem = useCallback(async (item: Item) => { @@ -129,21 +155,24 @@ function useItemsManager(initialItems: Item[]): { return { items, + layers, updateItem, addItem, removeItem, resetItems, setItemsApi, setItemsData, - allItemsLoaded, } } export const ItemsProvider: React.FunctionComponent<{ initialItems: Item[] + initialLayers: LayerState children?: React.ReactNode -}> = ({ initialItems, children }) => ( - {children} +}> = ({ initialItems, initialLayers, children }) => ( + + {children} + ) export const useItems = (): Item[] => { @@ -181,7 +210,12 @@ export const useSetItemsData = (): UseItemManagerResult['setItemsData'] => { return setItemsData } -export const useAllItemsLoaded = (): UseItemManagerResult['allItemsLoaded'] => { - const { allItemsLoaded } = useContext(ItemContext) - return allItemsLoaded +export const useLayers = (): LayerProps[] => { + const { layers } = useContext(ItemContext) + return layers.map((layer) => layer.props) +} + +export const useLayerState = (): LayerState => { + const { layers } = useContext(ItemContext) + return layers } diff --git a/lib/src/Components/Map/hooks/useLayers.tsx b/lib/src/Components/Map/hooks/useLayers.tsx deleted file mode 100644 index 963063b09..000000000 --- a/lib/src/Components/Map/hooks/useLayers.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useCallback, useReducer, createContext, useContext } from 'react' - -import type { LayerProps } from '#types/LayerProps' - -interface ActionType { - type: 'ADD LAYER' - layer: LayerProps -} - -type UseItemManagerResult = ReturnType - -const LayerContext = createContext({ - layers: [], - // eslint-disable-next-line @typescript-eslint/no-empty-function - addLayer: () => {}, -}) - -function useLayerManager(initialLayers: LayerProps[]): { - layers: LayerProps[] - addLayer: (layer: LayerProps) => void -} { - const [layers, dispatch] = useReducer((state: LayerProps[], action: ActionType) => { - switch (action.type) { - case 'ADD LAYER': - // eslint-disable-next-line no-case-declarations - const exist = state.find((layer) => layer.name === action.layer.name) - if (!exist) { - return [...state, action.layer] - } else return state - default: - throw new Error() - } - }, initialLayers) - - const addLayer = useCallback((layer: LayerProps) => { - dispatch({ - type: 'ADD LAYER', - layer, - }) - }, []) - - return { layers, addLayer } -} - -export const LayersProvider: React.FunctionComponent<{ - initialLayers: LayerProps[] - children?: React.ReactNode -}> = ({ initialLayers, children }: { initialLayers: LayerProps[]; children?: React.ReactNode }) => ( - {children} -) - -export const useLayers = (): LayerProps[] => { - const { layers } = useContext(LayerContext) - return layers -} - -export const useAddLayer = (): UseItemManagerResult['addLayer'] => { - const { addLayer } = useContext(LayerContext) - return addLayer -} diff --git a/lib/src/Components/Map/hooks/useMyProfile.ts b/lib/src/Components/Map/hooks/useMyProfile.ts new file mode 100644 index 000000000..f8f2c05a7 --- /dev/null +++ b/lib/src/Components/Map/hooks/useMyProfile.ts @@ -0,0 +1,23 @@ +import { useAuth } from '#components/Auth/useAuth' + +import { useItems, useLayerState } from './useItems' + +export const useMyProfile = () => { + const items = useItems() + const layers = useLayerState() + + const user = useAuth().user + + const isUserProfileLayerLoaded = layers.some( + (layer) => layer.props.userProfileLayer && layer.isInitialized, + ) + + const isMyProfileLoaded = isUserProfileLayerLoaded && !!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 000000000..800766a88 --- /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 000000000..852a6f18c --- /dev/null +++ b/lib/src/Components/Onboarding/index.ts @@ -0,0 +1 @@ +export { InvitePage } from './InvitePage' diff --git a/lib/src/Components/Profile/ProfileForm.tsx b/lib/src/Components/Profile/ProfileForm.tsx index b4d82862c..62e5850b6 100644 --- a/lib/src/Components/Profile/ProfileForm.tsx +++ b/lib/src/Components/Profile/ProfileForm.tsx @@ -6,7 +6,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { useAuth } from '#components/Auth/useAuth' import { useItems, useUpdateItem, useAddItem } from '#components/Map/hooks/useItems' -import { useLayers } from '#components/Map/hooks/useLayers' +import { useLayers } from '#components/Map/hooks/useItems' import { useHasUserPermission } from '#components/Map/hooks/usePermissions' import { useAddTag, useGetItemTags, useTags } from '#components/Map/hooks/useTags' import { MapOverlayPage } from '#components/Templates' diff --git a/lib/src/Components/Profile/ProfileView.tsx b/lib/src/Components/Profile/ProfileView.tsx index 498f0347d..7f3eda939 100644 --- a/lib/src/Components/Profile/ProfileView.tsx +++ b/lib/src/Components/Profile/ProfileView.tsx @@ -14,7 +14,7 @@ import { useLocation, useNavigate } from 'react-router-dom' import { useClusterRef } from '#components/Map/hooks/useClusterRef' import { useItems, useRemoveItem, useUpdateItem } from '#components/Map/hooks/useItems' -import { useLayers } from '#components/Map/hooks/useLayers' +import { useLayers } from '#components/Map/hooks/useItems' import { useLeafletRefs } from '#components/Map/hooks/useLeafletRefs' import { useHasUserPermission } from '#components/Map/hooks/usePermissions' import { useSelectPosition, useSetSelectPosition } from '#components/Map/hooks/useSelectPosition' 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 000000000..ece8b6900 --- /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 000000000..34bf615b0 --- /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 000000000..7602ca9f9 --- /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) throw new Error('Item does not have relations defined.') + + 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 000000000..0340551ff --- /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 87a5dca11..594060700 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 845898c34..e3024c450 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/Components/Templates/OverlayItemsIndexPage.tsx b/lib/src/Components/Templates/OverlayItemsIndexPage.tsx index 094ec95f1..5557c2fba 100644 --- a/lib/src/Components/Templates/OverlayItemsIndexPage.tsx +++ b/lib/src/Components/Templates/OverlayItemsIndexPage.tsx @@ -11,7 +11,7 @@ import { useAuth } from '#components/Auth/useAuth' import { TextInput } from '#components/Input' import { useFilterTags } from '#components/Map/hooks/useFilter' import { useAddItem, useItems, useRemoveItem } from '#components/Map/hooks/useItems' -import { useLayers } from '#components/Map/hooks/useLayers' +import { useLayers } from '#components/Map/hooks/useItems' import { useAddTag, useGetItemTags, useTags } from '#components/Map/hooks/useTags' import { Control } from '#components/Map/Subcomponents/Controls/Control' import { SearchControl } from '#components/Map/Subcomponents/Controls/SearchControl' diff --git a/lib/src/index.tsx b/lib/src/index.tsx index 8a9d5e89a..f3239c11e 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 000000000..a4a335c63 --- /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 7dd9dcf12..12d4b8e25 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]