From 3ce71da9ffe11097285f5cdcc66461a3d0a2b073 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Mon, 23 Jun 2025 23:45:55 +0200 Subject: [PATCH 01/23] Add component to show invite link (WIP) --- .../Profile/Subcomponents/InviteLinkView.tsx | 29 +++++++++++++++++++ .../Components/Profile/Templates/FlexView.tsx | 2 ++ lib/src/types/Item.d.ts | 5 ++++ 3 files changed, 36 insertions(+) create mode 100644 lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx diff --git a/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx b/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx new file mode 100644 index 000000000..4f5cb8e22 --- /dev/null +++ b/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx @@ -0,0 +1,29 @@ +import { TextView } from '#components/Map/Subcomponents/ItemPopupComponents' + +import type { Item } from '#types/Item' + +export const InviteLinkView = ({ item }: { item: Item }) => { + // TODO Only show if user has permission to view secrets. + // usePermission() seems to be useful. + + if (!item.secrets || item.secrets.length === 0) { + console.log('No secrets found for item', item.id) + // Generate a new secret if none exists? + return + } + const link = `${window.location.origin}/invite/${item.secrets[0].secret}` + + return ( +
+

Invite

+
+ +
+
+ ) +} diff --git a/lib/src/Components/Profile/Templates/FlexView.tsx b/lib/src/Components/Profile/Templates/FlexView.tsx index 845898c34..4a32f53fc 100644 --- a/lib/src/Components/Profile/Templates/FlexView.tsx +++ b/lib/src/Components/Profile/Templates/FlexView.tsx @@ -4,6 +4,7 @@ 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' @@ -17,6 +18,7 @@ const componentMap = { startEnd: ProfileStartEndView, gallery: GalleryView, crowdfundings: CrowdfundingView, + inviteLinks: InviteLinkView, // weitere Komponenten hier } diff --git a/lib/src/types/Item.d.ts b/lib/src/types/Item.d.ts index be3d6194a..bcf5026e0 100644 --- a/lib/src/types/Item.d.ts +++ b/lib/src/types/Item.d.ts @@ -16,6 +16,10 @@ interface GalleryItem { } } +interface ItemSecret { + secret: string +} + /** * @category Types */ @@ -53,6 +57,7 @@ export interface Item { next_appointment?: string gallery?: GalleryItem[] openCollectiveSlug?: string + secrets?: ItemSecret[] // { // coordinates: [number, number] From 505ec1015266564d9779d78cd460482caaffd0f6 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Tue, 24 Jun 2025 23:39:12 +0200 Subject: [PATCH 02/23] Show invite link with copy functionality and QR-Code, add tests --- lib/package-lock.json | 20 +++++ lib/package.json | 1 + lib/rollup.config.js | 1 + .../Subcomponents/InviteLinkView.spec.tsx | 75 +++++++++++++++++++ .../Profile/Subcomponents/InviteLinkView.tsx | 29 ++++--- .../InviteLinkView.spec.tsx.snap | 62 +++++++++++++++ 6 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx create mode 100644 lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap diff --git a/lib/package-lock.json b/lib/package-lock.json index 68cddb522..233274c8b 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.16.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 b65f95628..95c0c90c8 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.16.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/Profile/Subcomponents/InviteLinkView.spec.tsx b/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx new file mode 100644 index 000000000..f300ae62e --- /dev/null +++ b/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx @@ -0,0 +1,75 @@ +import { render, screen, 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 index 4f5cb8e22..34bf615b0 100644 --- a/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx +++ b/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx @@ -1,28 +1,37 @@ -import { TextView } from '#components/Map/Subcomponents/ItemPopupComponents' +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 }) => { - // TODO Only show if user has permission to view secrets. - // usePermission() seems to be useful. + // Only show if user has permission to view secrets. + if (!item.secrets || item.secrets.length === 0) return - if (!item.secrets || item.secrets.length === 0) { - console.log('No secrets found for item', item.id) - // Generate a new secret if none exists? - 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/__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 +

+
+ + +
+
+ + + + +
+
+`; From b0a5f2f9ad675ce978ec3629ff3baf40b203c726 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Wed, 25 Jun 2025 19:50:33 +0200 Subject: [PATCH 03/23] Query secrets --- frontend/src/api/itemsApi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/api/itemsApi.ts b/frontend/src/api/itemsApi.ts index c67ccc5f1..3661f2bc9 100644 --- a/frontend/src/api/itemsApi.ts +++ b/frontend/src/api/itemsApi.ts @@ -45,6 +45,7 @@ export class itemsApi implements ItemsApi { readItems(this.collectionName as never, { fields: [ '*', + 'secrets.*', 'to.*', 'relations.*', 'user_created.*', From d5e730134e78df9cc027406a2055c642365e79b1 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Wed, 25 Jun 2025 20:19:41 +0200 Subject: [PATCH 04/23] Update directus collections --- frontend/src/api/directus.ts | 11 +++++++++++ frontend/src/api/itemsApi.ts | 14 +++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/frontend/src/api/directus.ts b/frontend/src/api/directus.ts index 032be4b43..7b32f8b42 100644 --- a/frontend/src/api/directus.ts +++ b/frontend/src/api/directus.ts @@ -6,6 +6,7 @@ import { createDirectus, rest, authentication } from '@directus/sdk' import type { AuthenticationData, AuthenticationStorage } from '@directus/sdk' import type { Point } from 'geojson' +import type { Item } from 'utopia-ui' export interface Place { id: string @@ -50,6 +51,11 @@ interface CustomUserFields { position: Point } +interface ItemSecret { + secret: string + item: string +} + export interface MyCollections { places: Place[] events: Event[] @@ -57,6 +63,11 @@ export interface MyCollections { tags: Tag[] projects: Project[] directus_users: CustomUserFields[] + item_secrets: ItemSecret[] + items: Item[] + team: any[] + features: any[] + attestations: any[] } export const authLocalStorage = (mainKey = 'directus_storage') => diff --git a/frontend/src/api/itemsApi.ts b/frontend/src/api/itemsApi.ts index 3661f2bc9..defb7c995 100644 --- a/frontend/src/api/itemsApi.ts +++ b/frontend/src/api/itemsApi.ts @@ -12,14 +12,14 @@ import type { MyCollections } from './directus' import type { ItemsApi } from 'utopia-ui' export class itemsApi implements ItemsApi { - collectionName: string + collectionName: keyof MyCollections filter: any layerId: string | undefined mapId: string | undefined customParameter: any constructor( - collectionName: string, + collectionName: keyof MyCollections, layerId?: string | undefined, mapId?: string | undefined, filter?: any, @@ -82,7 +82,7 @@ export class itemsApi implements ItemsApi { async createItem(item: T & { id?: string }): Promise { try { const result = await directusClient.request( - createItem(this.collectionName as keyof MyCollections, { + createItem(this.collectionName, { ...item, ...(this.customParameter && this.customParameter), ...(this.layerId && { layer: this.layerId }), @@ -100,9 +100,7 @@ export class itemsApi implements ItemsApi { async updateItem(item: T & { id?: string }): Promise { try { - const result = await directusClient.request( - updateItem(this.collectionName as keyof MyCollections, item.id!, item), - ) + const result = await directusClient.request(updateItem(this.collectionName, item.id!, item)) return result as T } catch (error: any) { console.log(error) @@ -113,9 +111,7 @@ export class itemsApi implements ItemsApi { async deleteItem(id: string): Promise { try { - const result = await directusClient.request( - deleteItem(this.collectionName as keyof MyCollections, id), - ) + const result = await directusClient.request(deleteItem(this.collectionName, id)) return result as unknown as boolean } catch (error: any) { console.log(error) From b3c8b7fc67122a97068b7d96f3716ab64680f8f0 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Wed, 25 Jun 2025 20:50:34 +0200 Subject: [PATCH 05/23] Add config and invite api --- frontend/.env | 6 ++- frontend/src/App.tsx | 1 + frontend/src/api/inviteApi.ts | 69 +++++++++++++++++++++++++++++++++++ frontend/src/config/index.ts | 7 ++++ frontend/tsconfig.json | 10 ++++- 5 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 frontend/src/api/inviteApi.ts create mode 100644 frontend/src/config/index.ts diff --git a/frontend/.env b/frontend/.env index cb2852912..b6b98fb1b 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1 +1,5 @@ -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=todo diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 82dc26f8a..f0de565dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -175,6 +175,7 @@ function App() { }> + } /> } /> } /> { + try { + const response = await fetch( + `${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}/${inviteId}`, + { + method: 'GET', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + + if (!response.ok) { + return null + } + + const data = (await response.json()) as InvitingProfileResponse + + return data.id + } 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 validateInvite(inviteId: string): Promise { + const invitingProfileId = await this.getInvitingProfileId(inviteId) + + return invitingProfileId !== null + } + + async redeemInvite(inviteId: string): Promise { + try { + const response = await fetch( + `${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}/${inviteId}`, + { + method: 'GET', + mode: 'cors', + headers: { + 'Content-Type': 'application/json', + }, + }, + ) + + return response.ok + } 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/frontend/src/config/index.ts b/frontend/src/config/index.ts new file mode 100644 index 000000000..c908112c5 --- /dev/null +++ b/frontend/src/config/index.ts @@ -0,0 +1,7 @@ +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 ?? 'todo'), +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 9720a1eba..f7d59d5eb 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -17,8 +17,14 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + + /* Paths */ + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + } }, - "include": ["src"], + "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } From de89cd02b25330b445fc6331dfc6983f02dbc860 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Wed, 25 Jun 2025 22:16:00 +0200 Subject: [PATCH 06/23] Let vite resolve paths using tsconfig --- frontend/package-lock.json | 50 ++++++++++++++++++++++++++++++++++++-- frontend/package.json | 3 ++- frontend/vite.config.ts | 33 +++++++++++-------------- 3 files changed, 64 insertions(+), 22 deletions(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 627630573..622392954 100644 --- a/frontend/package-lock.json +++ b/frontend/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.105" + "utopia-ui": "^3.0.105", + "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/frontend/package.json b/frontend/package.json index 280f172d7..078359afe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,8 @@ "react-dom": "^18.2.0", "react-rnd": "^10.4.1", "react-router-dom": "^6.23.0", - "utopia-ui": "^3.0.105" + "utopia-ui": "^3.0.105", + "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { "@eslint-community/eslint-plugin-eslint-comments": "^4.4.1", diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e9533b8fd..650bd751d 100644 --- a/frontend/vite.config.ts +++ b/frontend/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 tsConfigPaths from 'vite-tsconfig-paths' +import fs from 'fs' +import path from 'path' // __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,18 +19,12 @@ export default defineConfig({ * }, */ }, - plugins: [ - react(), - tailwindcss(), - ], + plugins: [react(), tailwindcss(), tsConfigPaths()], build: { rollupOptions: { output: { manualChunks(id) { - if ( - id.includes('node_modules/utopia-ui/dist/Profile') && - /\.(esm|cjs)\.js$/.test(id) - ) { + if (id.includes('node_modules/utopia-ui/dist/Profile') && /\.(esm|cjs)\.js$/.test(id)) { return 'profile-form' } @@ -40,8 +35,8 @@ export default defineConfig({ if (id.includes('node_modules/')) { return 'vendor' } - } - } - } - } -}); + }, + }, + }, + }, +}) From f1d1943978954ca080daa38bd2b75a5c393c6893 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Wed, 25 Jun 2025 23:00:06 +0200 Subject: [PATCH 07/23] 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 f0de565dd..3fbd2747a 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 60a8679fc..043df0722 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 000000000..a7104fa16 --- /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 000000000..852a6f18c --- /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 498db1909..19f8846fa 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 000000000..b180a5cea --- /dev/null +++ b/lib/src/types/InviteApi.d.ts @@ -0,0 +1,4 @@ +export interface InviteApi { + validateInvite(inviteId: string): Promise + redeemInvite(inviteId: string): Promise +} From 2b3b3d5889de3315a08e45a879615a9e86f00fd6 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Wed, 25 Jun 2025 23:09:06 +0200 Subject: [PATCH 08/23] Redirect to inviting profile when redeeming --- lib/src/Components/Auth/useAuth.tsx | 10 ++++++++-- lib/src/Components/Onboarding/InvitePage.tsx | 5 ++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/src/Components/Auth/useAuth.tsx b/lib/src/Components/Auth/useAuth.tsx index 043df0722..fc69408f7 100644 --- a/lib/src/Components/Auth/useAuth.tsx +++ b/lib/src/Components/Auth/useAuth.tsx @@ -1,4 +1,5 @@ import { createContext, useState, useContext, useEffect, useCallback } from 'react' +import { useNavigate } from 'react-router-dom' import type { InviteApi } from '#types/InviteApi' import type { UserApi } from '#types/UserApi' @@ -49,6 +50,7 @@ const AuthContext = createContext({ * @category Auth */ export const AuthProvider = ({ userApi, inviteApi, children }: AuthProviderProps) => { + const navigate = useNavigate() const [user, setUser] = useState(null) const [token, setToken] = useState() const [loading, setLoading] = useState(true) @@ -80,13 +82,17 @@ export const AuthProvider = ({ userApi, inviteApi, children }: AuthProviderProps try { const user = await userApi.login(credentials.email, credentials.password) setToken(user?.access_token) + const fullUser = await loadUser() const inviteCode = localStorage.getItem('inviteCode') if (inviteCode) { // If an invite code is stored, redeem it - await inviteApi.redeemInvite(inviteCode) + const invitingProfileId = await inviteApi.redeemInvite(inviteCode) localStorage.removeItem('inviteCode') // Clear invite code after redeeming + if (invitingProfileId) { + navigate(`/item/${invitingProfileId}`) + } } - return await loadUser() + return fullUser } catch (error) { setLoading(false) throw error diff --git a/lib/src/Components/Onboarding/InvitePage.tsx b/lib/src/Components/Onboarding/InvitePage.tsx index a7104fa16..77bb382e9 100644 --- a/lib/src/Components/Onboarding/InvitePage.tsx +++ b/lib/src/Components/Onboarding/InvitePage.tsx @@ -28,18 +28,17 @@ export function InvitePage({ inviteApi }: Props) { const invitingProfileId = await inviteApi.redeemInvite(id) if (invitingProfileId) { toast.success('Invite redeemed successfully!') - navigate(`/item/${id}`) + navigate(`/item/${invitingProfileId}`) } else { toast.error('Failed to redeem invite') + navigate('/') } - navigate('/') } if (isLoadingAuthentication) return if (isAuthenticated) { void redeemInvite() - navigate('/') } else { // Save invite code in local storage localStorage.setItem('inviteCode', id) From c0f4715bc8ff447025462f6606a5dbe209595776 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Thu, 26 Jun 2025 00:01:30 +0200 Subject: [PATCH 09/23] Fix some logic with login and redeeming --- frontend/.env | 1 - frontend/src/App.tsx | 5 +-- frontend/src/config/index.ts | 1 + lib/src/Components/Auth/LoginPage.tsx | 32 ++++++++++++++++---- lib/src/Components/Auth/useAuth.tsx | 26 ++++++++-------- lib/src/Components/Onboarding/InvitePage.tsx | 6 ++-- 6 files changed, 45 insertions(+), 26 deletions(-) diff --git a/frontend/.env b/frontend/.env index b6b98fb1b..038109d35 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,5 +1,4 @@ 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=todo diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3fbd2747a..4a3833bbc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -52,6 +52,7 @@ import { Landingpage } from './pages/Landingpage' import MapContainer from './pages/MapContainer' import { getBottomRoutes, routes } from './routes/sidebar' import { InviteApi } from './api/InviteApi' +import { config } from '@/config' const ProfileForm = lazy(() => import('utopia-ui/Profile').then((mod) => ({ @@ -164,7 +165,7 @@ function App() { assetsApi={new assetsApi('https://api.utopia-lab.org/assets/')} appName={map.name} embedded={embedded} - openCollectiveApiKey={import.meta.env.VITE_OPEN_COLLECTIVE_API_KEY} + openCollectiveApiKey={config.openCollectiveApiKey} > }> } /> - } /> + } /> } /> ('') const [password, setPassword] = useState('') @@ -17,12 +23,26 @@ export function LoginPage() { const navigate = useNavigate() - // eslint-disable-next-line react-hooks/exhaustive-deps - const onLogin = async () => { + const handleSuccess = useCallback(async () => { + const inviteCode = localStorage.getItem('inviteCode') + let invitingProfileId: string | null = null + if (inviteCode) { + // If an invite code is stored, redeem it + invitingProfileId = await inviteApi.redeemInvite(inviteCode) + localStorage.removeItem('inviteCode') // Clear invite code after redeeming + } + if (invitingProfileId) { + navigate(`/item/${invitingProfileId}`) + } else { + navigate('/') + } + }, [inviteApi, navigate]) + + 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 +56,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 fc69408f7..dadd24589 100644 --- a/lib/src/Components/Auth/useAuth.tsx +++ b/lib/src/Components/Auth/useAuth.tsx @@ -1,5 +1,4 @@ import { createContext, useState, useContext, useEffect, useCallback } from 'react' -import { useNavigate } from 'react-router-dom' import type { InviteApi } from '#types/InviteApi' import type { UserApi } from '#types/UserApi' @@ -22,6 +21,7 @@ interface AuthCredentials { interface AuthContextProps { isAuthenticated: boolean + isInitialized: boolean user: UserItem | null login: (credentials: AuthCredentials) => Promise register: (credentials: AuthCredentials, userName: string) => Promise @@ -35,6 +35,7 @@ interface AuthContextProps { const AuthContext = createContext({ isAuthenticated: false, + isInitialized: false, user: null, login: () => Promise.reject(Error('Unimplemented')), register: () => Promise.reject(Error('Unimplemented')), @@ -49,11 +50,11 @@ const AuthContext = createContext({ /** * @category Auth */ -export const AuthProvider = ({ userApi, inviteApi, children }: AuthProviderProps) => { - const navigate = useNavigate() +export const AuthProvider = ({ userApi, children }: AuthProviderProps) => { const [user, setUser] = useState(null) const [token, setToken] = useState() - const [loading, setLoading] = useState(true) + const [loading, setLoading] = useState(false) + const [isInitialized, setIsInitialized] = useState(false) const isAuthenticated = !!user const loadUser: () => Promise = useCallback(async () => { @@ -65,11 +66,15 @@ export const AuthProvider = ({ userApi, inviteApi, 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]) @@ -83,15 +88,7 @@ export const AuthProvider = ({ userApi, inviteApi, children }: AuthProviderProps const user = await userApi.login(credentials.email, credentials.password) setToken(user?.access_token) const fullUser = await loadUser() - const inviteCode = localStorage.getItem('inviteCode') - if (inviteCode) { - // If an invite code is stored, redeem it - const invitingProfileId = await inviteApi.redeemInvite(inviteCode) - localStorage.removeItem('inviteCode') // Clear invite code after redeeming - if (invitingProfileId) { - navigate(`/item/${invitingProfileId}`) - } - } + return fullUser } catch (error) { setLoading(false) @@ -163,6 +160,7 @@ export const AuthProvider = ({ userApi, inviteApi, children }: AuthProviderProps () const navigate = useNavigate() @@ -35,7 +35,7 @@ export function InvitePage({ inviteApi }: Props) { } } - if (isLoadingAuthentication) return + if (!isAuthenticationInitialized) return if (isAuthenticated) { void redeemInvite() @@ -46,7 +46,7 @@ export function InvitePage({ inviteApi }: Props) { // Redirect to login page navigate('/login') } - }, [id, isAuthenticated, inviteApi, navigate, isLoadingAuthentication]) + }, [id, isAuthenticated, inviteApi, navigate, isAuthenticationInitialized]) return ( From 7388a02afdc9bd8b51e5e0691c6e7a946008c7d2 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Thu, 26 Jun 2025 00:05:21 +0200 Subject: [PATCH 10/23] Use correct redeem flow --- frontend/.env | 2 +- frontend/src/api/inviteApi.ts | 3 +-- frontend/src/config/index.ts | 4 +++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/.env b/frontend/.env index 038109d35..4d364d320 100644 --- a/frontend/.env +++ b/frontend/.env @@ -1,4 +1,4 @@ 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=todo +VITE_REDEEM_INVITE_FLOW_ID=cc80ec73-ecf5-4789-bee5-1127fb1a6ed4 diff --git a/frontend/src/api/inviteApi.ts b/frontend/src/api/inviteApi.ts index adcfb9ad4..44a36c728 100644 --- a/frontend/src/api/inviteApi.ts +++ b/frontend/src/api/inviteApi.ts @@ -39,8 +39,7 @@ export class InviteApi { async redeemInvite(inviteId: string): Promise { try { const response = await fetch( - // `${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}?secret=${inviteId}`, - `${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}?secret=${inviteId}`, + `${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}?secret=${inviteId}`, { method: 'GET', mode: 'cors', diff --git a/frontend/src/config/index.ts b/frontend/src/config/index.ts index c57b213fa..36f9e8f3b 100644 --- a/frontend/src/config/index.ts +++ b/frontend/src/config/index.ts @@ -3,6 +3,8 @@ export const config = { 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 ?? 'todo'), + 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 ?? ''), } From 814229e8555c39d7ae11c69e83f9e78567e1df20 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Thu, 26 Jun 2025 00:25:14 +0200 Subject: [PATCH 11/23] Hide missing form error --- lib/src/Components/Profile/Templates/FlexForm.tsx | 1 + 1 file changed, 1 insertion(+) 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 } From 5dba5e0ca97e967333c3941f6255f494c4dd6c66 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Fri, 27 Jun 2025 15:28:56 +0200 Subject: [PATCH 12/23] Add basic relations view --- .../Profile/Subcomponents/RelationsView.tsx | 37 +++++++++++++++++++ .../Components/Profile/Templates/FlexView.tsx | 2 + 2 files changed, 39 insertions(+) create mode 100644 lib/src/Components/Profile/Subcomponents/RelationsView.tsx 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/Templates/FlexView.tsx b/lib/src/Components/Profile/Templates/FlexView.tsx index 4a32f53fc..e3024c450 100644 --- a/lib/src/Components/Profile/Templates/FlexView.tsx +++ b/lib/src/Components/Profile/Templates/FlexView.tsx @@ -7,6 +7,7 @@ import { GroupSubHeaderView } from '#components/Profile/Subcomponents/GroupSubHe 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' @@ -19,6 +20,7 @@ const componentMap = { gallery: GalleryView, crowdfundings: CrowdfundingView, inviteLinks: InviteLinkView, + relations: RelationsView, // weitere Komponenten hier } From 4209bf20d0aa08c282b429229196cf0ba01b5315 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Fri, 27 Jun 2025 16:47:05 +0200 Subject: [PATCH 13/23] Pass profile to redeem Api and adapt to changed redeem flow --- frontend/src/App.tsx | 7 ++-- frontend/src/api/inviteApi.ts | 37 +++++++++++++------- frontend/src/api/userApi.ts | 4 +-- lib/src/Components/Auth/LoginPage.tsx | 23 +++++++++--- lib/src/Components/Map/hooks/useMyProfile.ts | 15 ++++++++ lib/src/Components/Onboarding/InvitePage.tsx | 13 +++++-- lib/src/types/InviteApi.d.ts | 2 +- 7 files changed, 76 insertions(+), 25 deletions(-) create mode 100644 lib/src/Components/Map/hooks/useMyProfile.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 4a3833bbc..f3f446121 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -46,7 +46,7 @@ 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' @@ -72,7 +72,8 @@ const UserSettings = lazy(() => })), ) -const inviteApi = new InviteApi() +const userApi = new UserApi() +const inviteApi = new InviteApi(userApi) function App() { const [permissionsApiInstance, setPermissionsApiInstance] = useState() @@ -160,7 +161,7 @@ function App() { if (map && layers) return (
- + { try { const response = await fetch( @@ -36,24 +44,27 @@ export class InviteApi { } } - async redeemInvite(inviteId: string): Promise { + async redeemInvite(inviteId: string, itemId: string): Promise { try { - const response = await fetch( - `${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}?secret=${inviteId}`, - { - method: 'GET', - mode: 'cors', - headers: { - 'Content-Type': 'application/json', - }, + 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 - const data = (await response.json()) as InvitingProfileResponse - - return data[0].item + return (await response.json()) as string } catch (error: unknown) { // eslint-disable-next-line no-console console.error('Error fetching inviting profile:', error) diff --git a/frontend/src/api/userApi.ts b/frontend/src/api/userApi.ts index 8876e02fd..c63e1baa1 100644 --- a/frontend/src/api/userApi.ts +++ b/frontend/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/lib/src/Components/Auth/LoginPage.tsx b/lib/src/Components/Auth/LoginPage.tsx index bea8c0905..f3e55ca5f 100644 --- a/lib/src/Components/Auth/LoginPage.tsx +++ b/lib/src/Components/Auth/LoginPage.tsx @@ -2,6 +2,7 @@ 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' @@ -21,22 +22,36 @@ export function LoginPage({ inviteApi }: Props) { const { login, loading } = useAuth() + const myProfile = useMyProfile() + const navigate = useNavigate() + const redeemInvite = useCallback( + async (inviteCode: string): Promise => { + 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, myProfile], + ) + const handleSuccess = useCallback(async () => { const inviteCode = localStorage.getItem('inviteCode') let invitingProfileId: string | null = null if (inviteCode) { - // If an invite code is stored, redeem it - invitingProfileId = await inviteApi.redeemInvite(inviteCode) - localStorage.removeItem('inviteCode') // Clear invite code after redeeming + invitingProfileId = await redeemInvite(inviteCode) } if (invitingProfileId) { navigate(`/item/${invitingProfileId}`) } else { navigate('/') } - }, [inviteApi, navigate]) + }, [navigate, redeemInvite]) const onLogin = useCallback(async () => { await toast.promise(login({ email, password }), { diff --git a/lib/src/Components/Map/hooks/useMyProfile.ts b/lib/src/Components/Map/hooks/useMyProfile.ts new file mode 100644 index 000000000..e4020c87e --- /dev/null +++ b/lib/src/Components/Map/hooks/useMyProfile.ts @@ -0,0 +1,15 @@ +import { useAuth } from '#components/Auth/useAuth' + +import { useItems } from './useItems' + +export const useMyProfile = () => { + const items = useItems() + const user = useAuth().user + + // Find the user's profile item + const myProfile = items.find( + (item) => item.layer?.userProfileLayer && item.user_created?.id === user?.id, + ) + + return myProfile +} diff --git a/lib/src/Components/Onboarding/InvitePage.tsx b/lib/src/Components/Onboarding/InvitePage.tsx index 78d7dbbac..c4bc82bfd 100644 --- a/lib/src/Components/Onboarding/InvitePage.tsx +++ b/lib/src/Components/Onboarding/InvitePage.tsx @@ -3,6 +3,7 @@ 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' @@ -19,13 +20,21 @@ export function InvitePage({ inviteApi }: Props) { const { id } = useParams<{ id: string }>() const navigate = useNavigate() + const myProfile = useMyProfile() + 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 (!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}`) @@ -46,7 +55,7 @@ export function InvitePage({ inviteApi }: Props) { // Redirect to login page navigate('/login') } - }, [id, isAuthenticated, inviteApi, navigate, isAuthenticationInitialized]) + }, [id, isAuthenticated, inviteApi, navigate, isAuthenticationInitialized, myProfile]) return ( diff --git a/lib/src/types/InviteApi.d.ts b/lib/src/types/InviteApi.d.ts index b180a5cea..a4a335c63 100644 --- a/lib/src/types/InviteApi.d.ts +++ b/lib/src/types/InviteApi.d.ts @@ -1,4 +1,4 @@ export interface InviteApi { validateInvite(inviteId: string): Promise - redeemInvite(inviteId: string): Promise + redeemInvite(inviteId: string, itemId: string): Promise } From 7eeb7f31c24d47a2358ce5d1726d1626c428d5f2 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Sat, 5 Jul 2025 16:27:00 +0200 Subject: [PATCH 14/23] Remove unnecessary aliases in vite config --- app/vite.config.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/vite.config.ts b/app/vite.config.ts index 5dfae1eb2..2be6ed483 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -22,15 +22,6 @@ export default defineConfig({ 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, From 250b843faef4a2a11ca23737b99dcb0ff3286724 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Sat, 5 Jul 2025 16:50:15 +0200 Subject: [PATCH 15/23] Remove dead import --- .../Components/Profile/Subcomponents/InviteLinkView.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx b/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx index f300ae62e..ece8b6900 100644 --- a/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx +++ b/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react' +import { render, fireEvent } from '@testing-library/react' import { describe, it, expect, beforeEach, vi } from 'vitest' import { InviteLinkView } from './InviteLinkView' From 509c89fb86c48c1446529e6ed794e570600f9712 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Sat, 5 Jul 2025 16:50:48 +0200 Subject: [PATCH 16/23] gitignore mac specific file --- app/.gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 17bd81d603fea7d7b61e750d109558bb4ea2ef51 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Sat, 5 Jul 2025 16:52:26 +0200 Subject: [PATCH 17/23] Remove lazy loading --- app/src/App.tsx | 23 ++++------------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index e6631809e..e43913f60 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -34,12 +34,15 @@ import { MarketView, SVG, LoadingMapOverlay, + ProfileForm, + ProfileView, + UserSettings, } from 'utopia-ui' import { Route, Routes } from 'react-router-dom' import './App.css' -import { lazy, Suspense, useEffect, useState } from 'react' +import { Suspense, useEffect, useState } from 'react' import { assetsApi } from './api/assetsApi' import { itemsApi } from './api/itemsApi' @@ -54,24 +57,6 @@ import { getBottomRoutes, routes } from './routes/sidebar' import { InviteApi } from './api/InviteApi' import { config } from '@/config' -const ProfileForm = lazy(() => - import('utopia-ui/Profile').then((mod) => ({ - default: mod.ProfileForm, - })), -) - -const ProfileView = lazy(() => - import('utopia-ui/Profile').then((mod) => ({ - default: mod.ProfileView, - })), -) - -const UserSettings = lazy(() => - import('utopia-ui/Profile').then((mod) => ({ - default: mod.UserSettings, - })), -) - const userApi = new UserApi() const inviteApi = new InviteApi(userApi) From 70e3f7f62d8e0a80c7afa1d405bd7b57c24f439d Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Sat, 5 Jul 2025 17:09:42 +0200 Subject: [PATCH 18/23] Fix linting --- app/.eslintrc.cjs | 2 +- app/src/App.tsx | 1 - app/src/api/inviteApi.ts | 5 +++-- app/src/config/index.ts | 2 ++ app/tsconfig.json | 2 +- 5 files changed, 7 insertions(+), 5 deletions(-) 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/src/App.tsx b/app/src/App.tsx index e43913f60..ea5938553 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -54,7 +54,6 @@ 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' import { config } from '@/config' const userApi = new UserApi() diff --git a/app/src/api/inviteApi.ts b/app/src/api/inviteApi.ts index f5aa77d6b..7a6d2c247 100644 --- a/app/src/api/inviteApi.ts +++ b/app/src/api/inviteApi.ts @@ -1,7 +1,8 @@ -import type { UserApi } from 'utopia-ui' - +/* @eslint-disable-next-line import/no-relative-parent-imports */ import { config } from '@/config' +import type { UserApi } from 'utopia-ui' + type InvitingProfileResponse = [ { item: string diff --git a/app/src/config/index.ts b/app/src/config/index.ts index 36f9e8f3b..28ab494c3 100644 --- a/app/src/config/index.ts +++ b/app/src/config/index.ts @@ -8,3 +8,5 @@ export const config = { ), 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 d6222cbb9..95a0cf499 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -35,7 +35,7 @@ ], "#root/*": [ "../lib/*" - ], + ] } }, "include": [ From 143378699cc423c5e3e2e350564e0558667acc2f Mon Sep 17 00:00:00 2001 From: Anton Tranelis Date: Tue, 8 Jul 2025 15:51:53 +0200 Subject: [PATCH 19/23] add InviteApi import --- app/src/App.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/App.tsx b/app/src/App.tsx index ea5938553..39b077724 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -55,6 +55,7 @@ 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) From 08cf110472959d8d1177f262a9719635a0607275 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Wed, 9 Jul 2025 18:52:03 +0200 Subject: [PATCH 20/23] Change case of file name (tbd) --- app/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 39b077724..8a6724dae 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -55,7 +55,7 @@ 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' +import { InviteApi } from './api/InviteApi' const userApi = new UserApi() const inviteApi = new InviteApi(userApi) From 438d590b853033bb1986b39fb880c736de572c56 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Wed, 9 Jul 2025 19:33:40 +0200 Subject: [PATCH 21/23] Don't toast error if user profile was not loaded yet --- lib/src/Components/Auth/LoginPage.tsx | 6 ++++-- lib/src/Components/Map/hooks/useMyProfile.ts | 9 +++++++-- lib/src/Components/Onboarding/InvitePage.tsx | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/lib/src/Components/Auth/LoginPage.tsx b/lib/src/Components/Auth/LoginPage.tsx index f3e55ca5f..a87bdd01b 100644 --- a/lib/src/Components/Auth/LoginPage.tsx +++ b/lib/src/Components/Auth/LoginPage.tsx @@ -22,12 +22,14 @@ export function LoginPage({ inviteApi }: Props) { const { login, loading } = useAuth() - const myProfile = useMyProfile() + const { myProfile, isMyProfileLoaded } = useMyProfile() const navigate = useNavigate() 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 @@ -37,7 +39,7 @@ export function LoginPage({ inviteApi }: Props) { localStorage.removeItem('inviteCode') // Clear invite code after redeeming return invitingProfileId }, - [inviteApi, myProfile], + [inviteApi, isMyProfileLoaded, myProfile], ) const handleSuccess = useCallback(async () => { diff --git a/lib/src/Components/Map/hooks/useMyProfile.ts b/lib/src/Components/Map/hooks/useMyProfile.ts index e4020c87e..e94c82ed6 100644 --- a/lib/src/Components/Map/hooks/useMyProfile.ts +++ b/lib/src/Components/Map/hooks/useMyProfile.ts @@ -1,15 +1,20 @@ import { useAuth } from '#components/Auth/useAuth' -import { useItems } from './useItems' +import { useItems, useAllItemsLoaded } from './useItems' export const useMyProfile = () => { 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 + return { myProfile, isMyProfileLoaded } } diff --git a/lib/src/Components/Onboarding/InvitePage.tsx b/lib/src/Components/Onboarding/InvitePage.tsx index c4bc82bfd..800766a88 100644 --- a/lib/src/Components/Onboarding/InvitePage.tsx +++ b/lib/src/Components/Onboarding/InvitePage.tsx @@ -20,7 +20,7 @@ export function InvitePage({ inviteApi }: Props) { const { id } = useParams<{ id: string }>() const navigate = useNavigate() - const myProfile = useMyProfile() + const { myProfile, isMyProfileLoaded } = useMyProfile() if (!id) throw new Error('Invite ID is required') @@ -28,6 +28,8 @@ export function InvitePage({ inviteApi }: Props) { 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 @@ -55,7 +57,15 @@ export function InvitePage({ inviteApi }: Props) { // Redirect to login page navigate('/login') } - }, [id, isAuthenticated, inviteApi, navigate, isAuthenticationInitialized, myProfile]) + }, [ + id, + isAuthenticated, + inviteApi, + navigate, + isAuthenticationInitialized, + myProfile, + isMyProfileLoaded, + ]) return ( From ed4cdce37c4c750709186b73b6cdc13c6b0cdb9d Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Thu, 10 Jul 2025 19:36:42 +0200 Subject: [PATCH 22/23] Fix casing --- app/src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/App.tsx b/app/src/App.tsx index 8a6724dae..39b077724 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -55,7 +55,7 @@ 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' +import { InviteApi } from './api/inviteApi' const userApi = new UserApi() const inviteApi = new InviteApi(userApi) From 81e2d53afa9d0313487cd74f1571307da0e37475 Mon Sep 17 00:00:00 2001 From: Maximilian Harz Date: Fri, 11 Jul 2025 01:32:29 +0200 Subject: [PATCH 23/23] Refactor layers and items into one common state (WIP) --- .../Components/AppShell/ContextWrapper.tsx | 63 ++++---- lib/src/Components/Item/PopupView.tsx | 8 +- lib/src/Components/Map/Layer.tsx | 138 +++++++++++------ .../Map/Subcomponents/AddButton.tsx | 2 +- .../Subcomponents/Controls/LayerControl.tsx | 2 +- lib/src/Components/Map/UtopiaMapInner.tsx | 8 +- lib/src/Components/Map/hooks/useFilter.tsx | 11 +- lib/src/Components/Map/hooks/useItems.tsx | 140 +++++++++++------- lib/src/Components/Map/hooks/useLayers.tsx | 60 -------- lib/src/Components/Map/hooks/useMyProfile.ts | 11 +- lib/src/Components/Profile/ProfileForm.tsx | 2 +- lib/src/Components/Profile/ProfileView.tsx | 2 +- .../Templates/OverlayItemsIndexPage.tsx | 2 +- 13 files changed, 240 insertions(+), 209 deletions(-) delete mode 100644 lib/src/Components/Map/hooks/useLayers.tsx 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/Item/PopupView.tsx b/lib/src/Components/Item/PopupView.tsx index 6eea6e43c..5495cfc56 100644 --- a/lib/src/Components/Item/PopupView.tsx +++ b/lib/src/Components/Item/PopupView.tsx @@ -7,8 +7,9 @@ import { useIsLayerVisible, useIsGroupTypeVisible, useVisibleGroupType, + useAllVisibleLayersInitialized, } from '#components/Map/hooks/useFilter' -import { useItems, useAllItemsLoaded } from '#components/Map/hooks/useItems' +import { useItems } from '#components/Map/hooks/useItems' import { useAddMarker, useAddPopup, useLeafletRefs } from '#components/Map/hooks/useLeafletRefs' import { useSetMarkerClicked, useSelectPosition } from '#components/Map/hooks/useSelectPosition' import { useGetItemTags, useAllTagsLoaded, useTags } from '#components/Map/hooks/useTags' @@ -44,7 +45,8 @@ export const PopupView = ({ children }: { children?: React.ReactNode }) => { 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 index e94c82ed6..f8f2c05a7 100644 --- a/lib/src/Components/Map/hooks/useMyProfile.ts +++ b/lib/src/Components/Map/hooks/useMyProfile.ts @@ -1,15 +1,18 @@ import { useAuth } from '#components/Auth/useAuth' -import { useItems, useAllItemsLoaded } from './useItems' +import { useItems, useLayerState } from './useItems' export const useMyProfile = () => { const items = useItems() - const allItemsLoaded = useAllItemsLoaded() + const layers = useLayerState() const user = useAuth().user - // allItemsLoaded is not reliable, so we check if items.length > 0 - const isMyProfileLoaded = allItemsLoaded && items.length > 0 && !!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( 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/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'