-
-
-
-
- Upload Image
-
+
+ Media
+ :
+
+
+
+
+
+
+
+
+ Upload Image
+
+
diff --git a/lib/src/Components/Profile/Templates/TabsForm.tsx b/lib/src/Components/Profile/Templates/TabsForm.tsx
index 272db754..be28d2bc 100644
--- a/lib/src/Components/Profile/Templates/TabsForm.tsx
+++ b/lib/src/Components/Profile/Templates/TabsForm.tsx
@@ -44,7 +44,6 @@ export const TabsForm = ({
setState((prevState) => ({
...prevState,
@@ -62,7 +61,6 @@ export const TabsForm = ({
From 855ef3de293dda73358a494c3fadb6fcd5c38707 Mon Sep 17 00:00:00 2001
From: Anton Tranelis <31516529+antontranelis@users.noreply.github.com>
Date: Fri, 4 Jul 2025 08:39:13 +0200
Subject: [PATCH 2/4] fix(lib): base layer config (#276)
* add close button to custom info modal
* added attribution
* fix build examples workflow
* fix pending tests
* Revert "add close button to custom info modal"
This reverts commit 835c661009abbdc5c095a6bc86bbd6890e080e5f.
---
.github/workflows/test.build.lib.yml | 2 +-
app/src/pages/MapContainer.tsx | 2 ++
lib/src/Components/Map/UtopiaMap.tsx | 8 +++++++
lib/src/Components/Map/UtopiaMapInner.tsx | 11 +++++++--
lib/src/assets/css/leaflet.css | 27 +++++++++++++++++++++--
lib/src/types/UtopiaMapProps.d.ts | 2 ++
6 files changed, 47 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/test.build.lib.yml b/.github/workflows/test.build.lib.yml
index 00e82f3b..99b3cd50 100644
--- a/.github/workflows/test.build.lib.yml
+++ b/.github/workflows/test.build.lib.yml
@@ -37,7 +37,7 @@ jobs:
build-examples:
if: needs.files-changed.outputs.build == 'true'
name: Test Example Apps
- needs: [build, files-changed]
+ needs: [files-changed, build]
runs-on: ubuntu-latest
strategy:
matrix:
diff --git a/app/src/pages/MapContainer.tsx b/app/src/pages/MapContainer.tsx
index 14d96c71..fe3140a1 100644
--- a/app/src/pages/MapContainer.tsx
+++ b/app/src/pages/MapContainer.tsx
@@ -84,6 +84,8 @@ function MapContainer({ layers, map }: { layers: LayerProps[]; map: any }) {
defaultTheme={map.default_theme}
showZoomControl={map.show_zoom_control}
expandLayerControl={map.expand_layer_control}
+ tileServerUrl={map.tile_server_url}
+ tileServerAttribution={map.tile_server_attribution}
>
{layers &&
apis &&
diff --git a/lib/src/Components/Map/UtopiaMap.tsx b/lib/src/Components/Map/UtopiaMap.tsx
index da909e3b..3ca2a6e3 100644
--- a/lib/src/Components/Map/UtopiaMap.tsx
+++ b/lib/src/Components/Map/UtopiaMap.tsx
@@ -56,6 +56,8 @@ function UtopiaMap({
defaultTheme,
donationWidget,
expandLayerControl,
+ tileServerUrl,
+ tileServerAttribution,
}: {
/** height of the map (default '500px') */
height?: string
@@ -85,6 +87,10 @@ function UtopiaMap({
donationWidget?: boolean
/** open layer control on map initialisation */
expandLayerControl?: boolean
+ /** configure a custom tile server */
+ tileServerUrl?: string
+ /** configure a custom tile server attribution */
+ tileServerAttribution?: string
}) {
return (
@@ -104,6 +110,8 @@ function UtopiaMap({
showThemeControl={showThemeControl}
defaultTheme={defaultTheme}
expandLayerControl={expandLayerControl}
+ tileServerUrl={tileServerUrl}
+ tileServerAttribution={tileServerAttribution}
>
{children}
diff --git a/lib/src/Components/Map/UtopiaMapInner.tsx b/lib/src/Components/Map/UtopiaMapInner.tsx
index ba7f02cc..d197f92a 100644
--- a/lib/src/Components/Map/UtopiaMapInner.tsx
+++ b/lib/src/Components/Map/UtopiaMapInner.tsx
@@ -55,6 +55,8 @@ export function UtopiaMapInner({
defaultTheme = '',
donationWidget,
expandLayerControl,
+ tileServerUrl,
+ tileServerAttribution,
}: {
children?: React.ReactNode
geo?: GeoJsonObject
@@ -65,6 +67,8 @@ export function UtopiaMapInner({
showThemeControl?: boolean
defaultTheme?: string
expandLayerControl?: boolean
+ tileServerUrl?: string
+ tileServerAttribution?: string
}) {
const selectNewItemPosition = useSelectPosition()
const setSelectNewItemPosition = useSetSelectPosition()
@@ -278,8 +282,11 @@ export function UtopiaMapInner({
OpenStreetMap'
+ }
+ url={tileServerUrl ?? 'https://tile.osmand.net/hd/{z}/{x}/{y}.png'}
/>
setClusterRef(r as any)}
diff --git a/lib/src/assets/css/leaflet.css b/lib/src/assets/css/leaflet.css
index d8fab67f..af2364c1 100644
--- a/lib/src/assets/css/leaflet.css
+++ b/lib/src/assets/css/leaflet.css
@@ -1,6 +1,29 @@
- .leaflet-control-attribution {
- display: none;
+ .leaflet-control-attribution{
+ color: #000 !important;
+ background-color: white/50 !important;
+ border-radius: 4px;
+ padding: 0 4px;
+ z-index: 400 !important;
+ font-size: 10px;
+ opacity: 0.5;
+}
+
+ .leaflet-control-attribution a{
+ color: #000 !important;
}
+
+ .leaflet-attribution-flag {
+display: none !important;
+ }
+
+ .leaflet-control-attribution a:not([href*="openstreetmap"]) {
+ display: none !important;
+ }
+
+ /* 2) Entfernt den Trenner “|” (liegt im span[aria-hidden]) */
+ .leaflet-control-attribution span[aria-hidden="true"] {
+ display: none !important;
+ }
.leaflet-control-locate {
display: none;
diff --git a/lib/src/types/UtopiaMapProps.d.ts b/lib/src/types/UtopiaMapProps.d.ts
index 3d85374e..b5aec550 100644
--- a/lib/src/types/UtopiaMapProps.d.ts
+++ b/lib/src/types/UtopiaMapProps.d.ts
@@ -18,4 +18,6 @@ export interface UtopiaMapProps {
donationWidget?: boolean
defaultTheme?: string
expandLayerControl?: boolean
+ tileServerUrl?: string
+ tileServerAttribution?: string
}
From 1e7320b8954ad292d04ecbfc15a7141bf72af9d5 Mon Sep 17 00:00:00 2001
From: Max
Date: Fri, 11 Jul 2025 13:37:05 +0200
Subject: [PATCH 3/4] feat(app): qR invites (#267)
* Add component to show invite link (WIP)
* Show invite link with copy functionality and QR-Code, add tests
* Query secrets
* Update directus collections
* Add config and invite api
* Let vite resolve paths using tsconfig
* Redeem invite link when logged in or after logging in
* Redirect to inviting profile when redeeming
* Fix some logic with login and redeeming
* Use correct redeem flow
* Hide missing form error
* Add basic relations view
* Pass profile to redeem Api and adapt to changed redeem flow
* Remove unnecessary aliases in vite config
* Remove dead import
* gitignore mac specific file
* Remove lazy loading
* Fix linting
* add InviteApi import
* Change case of file name (tbd)
* Don't toast error if user profile was not loaded yet
* Fix casing
* avoid app crash when profile of a new item is opened
---------
Co-authored-by: Anton Tranelis
---
app/.env | 5 +-
app/.eslintrc.cjs | 2 +-
app/.gitignore | 1 +
app/package-lock.json | 50 +++++++++++-
app/package.json | 1 +
app/src/App.tsx | 17 ++--
app/src/api/directus.ts | 6 ++
app/src/api/inviteApi.ts | 79 +++++++++++++++++++
app/src/api/itemsApi.ts | 1 +
app/src/api/userApi.ts | 4 +-
app/src/config/index.ts | 12 +++
app/tsconfig.json | 42 +++++++---
app/vite.config.ts | 40 ++++------
lib/package-lock.json | 20 +++++
lib/package.json | 1 +
lib/rollup.config.js | 1 +
lib/src/Components/Auth/LoginPage.tsx | 49 ++++++++++--
lib/src/Components/Auth/useAuth.tsx | 34 +++++---
lib/src/Components/Map/hooks/useMyProfile.ts | 20 +++++
lib/src/Components/Onboarding/InvitePage.tsx | 75 ++++++++++++++++++
lib/src/Components/Onboarding/index.ts | 1 +
.../Subcomponents/InviteLinkView.spec.tsx | 75 ++++++++++++++++++
.../Profile/Subcomponents/InviteLinkView.tsx | 38 +++++++++
.../Profile/Subcomponents/RelationsView.tsx | 37 +++++++++
.../InviteLinkView.spec.tsx.snap | 62 +++++++++++++++
.../Components/Profile/Templates/FlexForm.tsx | 1 +
.../Components/Profile/Templates/FlexView.tsx | 4 +
lib/src/index.tsx | 1 +
lib/src/types/InviteApi.d.ts | 4 +
lib/src/types/Item.d.ts | 5 ++
30 files changed, 623 insertions(+), 65 deletions(-)
create mode 100644 app/src/api/inviteApi.ts
create mode 100644 app/src/config/index.ts
create mode 100644 lib/src/Components/Map/hooks/useMyProfile.ts
create mode 100644 lib/src/Components/Onboarding/InvitePage.tsx
create mode 100644 lib/src/Components/Onboarding/index.ts
create mode 100644 lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx
create mode 100644 lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx
create mode 100644 lib/src/Components/Profile/Subcomponents/RelationsView.tsx
create mode 100644 lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap
create mode 100644 lib/src/types/InviteApi.d.ts
diff --git a/app/.env b/app/.env
index cb285291..4d364d32 100644
--- a/app/.env
+++ b/app/.env
@@ -1 +1,4 @@
-VITE_OPEN_COLLECTIVE_API_KEY=your_key
\ No newline at end of file
+VITE_OPEN_COLLECTIVE_API_KEY=your_key
+VITE_API_URL=https://api.utopia-lab.org
+VITE_VALIDATE_INVITE_FLOW_ID=01d61db0-25aa-4bfa-bc24-c6a8f208a455
+VITE_REDEEM_INVITE_FLOW_ID=cc80ec73-ecf5-4789-bee5-1127fb1a6ed4
diff --git a/app/.eslintrc.cjs b/app/.eslintrc.cjs
index 287f4b5d..af43f19b 100644
--- a/app/.eslintrc.cjs
+++ b/app/.eslintrc.cjs
@@ -82,7 +82,7 @@ module.exports = {
'import/no-relative-parent-imports': [
'error',
{
- ignore: ['#[src,types,root,components,utils,assets]/*'],
+ ignore: ['#[src,types,root,components,utils,assets]/*', '@/config/*'],
},
],
'import/no-self-import': 'error',
diff --git a/app/.gitignore b/app/.gitignore
index b9470778..3bdd52eb 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -1,2 +1,3 @@
node_modules/
dist/
+.DS_Store
diff --git a/app/package-lock.json b/app/package-lock.json
index f6897356..7bc582bd 100644
--- a/app/package-lock.json
+++ b/app/package-lock.json
@@ -18,7 +18,8 @@
"react-dom": "^18.2.0",
"react-rnd": "^10.4.1",
"react-router-dom": "^6.23.0",
- "utopia-ui": "^3.0.111"
+ "utopia-ui": "^3.0.111",
+ "vite-tsconfig-paths": "^5.1.4"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.1",
@@ -6740,6 +6741,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/globrex": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
+ "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==",
+ "license": "MIT"
+ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -10984,6 +10991,26 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/tsconfck": {
+ "version": "3.1.6",
+ "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz",
+ "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==",
+ "license": "MIT",
+ "bin": {
+ "tsconfck": "bin/tsconfck.js"
+ },
+ "engines": {
+ "node": "^18 || >=20"
+ },
+ "peerDependencies": {
+ "typescript": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
"node_modules/tsconfig-paths": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
@@ -11147,7 +11174,7 @@
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -11621,6 +11648,25 @@
}
}
},
+ "node_modules/vite-tsconfig-paths": {
+ "version": "5.1.4",
+ "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz",
+ "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "globrex": "^0.1.2",
+ "tsconfck": "^3.0.3"
+ },
+ "peerDependencies": {
+ "vite": "*"
+ },
+ "peerDependenciesMeta": {
+ "vite": {
+ "optional": true
+ }
+ }
+ },
"node_modules/vite/node_modules/fdir": {
"version": "6.4.4",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
diff --git a/app/package.json b/app/package.json
index 1f1ca1d3..1c6e9e38 100644
--- a/app/package.json
+++ b/app/package.json
@@ -20,6 +20,7 @@
"react-dom": "^18.2.0",
"react-rnd": "^10.4.1",
"react-router-dom": "^6.23.0",
+ "vite-tsconfig-paths": "^5.1.4",
"utopia-ui": "^3.0.111"
},
"devDependencies": {
diff --git a/app/src/App.tsx b/app/src/App.tsx
index 9b789c5e..39b07772 100644
--- a/app/src/App.tsx
+++ b/app/src/App.tsx
@@ -20,6 +20,7 @@ import {
Content,
AuthProvider,
Modal,
+ InvitePage,
LoginPage,
SignupPage,
Quests,
@@ -33,8 +34,8 @@ import {
MarketView,
SVG,
LoadingMapOverlay,
- ProfileView,
ProfileForm,
+ ProfileView,
UserSettings,
} from 'utopia-ui'
@@ -48,11 +49,16 @@ import { itemsApi } from './api/itemsApi'
import { layersApi } from './api/layersApi'
import { mapApi } from './api/mapApi'
import { permissionsApi } from './api/permissionsApi'
-import { userApi } from './api/userApi'
+import { UserApi } from './api/userApi'
import { ModalContent } from './ModalContent'
import { Landingpage } from './pages/Landingpage'
import MapContainer from './pages/MapContainer'
import { getBottomRoutes, routes } from './routes/sidebar'
+import { config } from '@/config'
+import { InviteApi } from './api/inviteApi'
+
+const userApi = new UserApi()
+const inviteApi = new InviteApi(userApi)
function App() {
const [permissionsApiInstance, setPermissionsApiInstance] = useState()
@@ -140,12 +146,12 @@ function App() {
if (map && layers)
return (
-
+
}>
- } />
+ } />
+ } />
} />
{
+ try {
+ const response = await fetch(
+ `${config.apiUrl}/flows/trigger/${config.validateInviteFlowId}?secret=${inviteId}`,
+ {
+ method: 'GET',
+ mode: 'cors',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ )
+
+ if (!response.ok) return null
+
+ const data = (await response.json()) as InvitingProfileResponse
+
+ return data[0].item
+ } catch (error: unknown) {
+ // eslint-disable-next-line no-console
+ console.error('Error fetching inviting profile:', error)
+ if (error instanceof Error && error.message) {
+ throw new Error(error.message)
+ } else {
+ throw new Error('An unknown error occurred while fetching the inviting profile.')
+ }
+ }
+ }
+
+ async redeemInvite(inviteId: string, itemId: string): Promise {
+ try {
+ const token = await this.userApi.getToken()
+
+ if (!token) {
+ throw new Error('User is not authenticated. Cannot redeem invite.')
+ }
+
+ const response = await fetch(`${config.apiUrl}/flows/trigger/${config.redeemInviteFlowId}`, {
+ method: 'POST',
+ mode: 'cors',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ secret: inviteId, item: itemId }),
+ })
+
+ if (!response.ok) return null
+
+ return (await response.json()) as string
+ } catch (error: unknown) {
+ // eslint-disable-next-line no-console
+ console.error('Error fetching inviting profile:', error)
+ if (error instanceof Error && error.message) {
+ throw new Error(error.message)
+ } else {
+ throw new Error('An unknown error occurred while fetching the inviting profile.')
+ }
+ }
+ }
+}
diff --git a/app/src/api/itemsApi.ts b/app/src/api/itemsApi.ts
index 854734ab..defb7c99 100644
--- a/app/src/api/itemsApi.ts
+++ b/app/src/api/itemsApi.ts
@@ -45,6 +45,7 @@ export class itemsApi implements ItemsApi {
readItems(this.collectionName as never, {
fields: [
'*',
+ 'secrets.*',
'to.*',
'relations.*',
'user_created.*',
diff --git a/app/src/api/userApi.ts b/app/src/api/userApi.ts
index 8876e02f..c63e1baa 100644
--- a/app/src/api/userApi.ts
+++ b/app/src/api/userApi.ts
@@ -8,7 +8,7 @@ import { createUser, passwordRequest, passwordReset, readMe, updateMe } from '@d
import { directusClient } from './directus'
-import type { UserApi, UserItem } from 'utopia-ui'
+import type { UserItem } from 'utopia-ui'
interface DirectusError {
errors: {
@@ -17,7 +17,7 @@ interface DirectusError {
}[]
}
-export class userApi implements UserApi {
+export class UserApi {
async register(email: string, password: string, userName: string): Promise {
try {
return await directusClient.request(createUser({ email, password, first_name: userName }))
diff --git a/app/src/config/index.ts b/app/src/config/index.ts
new file mode 100644
index 00000000..28ab494c
--- /dev/null
+++ b/app/src/config/index.ts
@@ -0,0 +1,12 @@
+export const config = {
+ apiUrl: String(import.meta.env.VITE_API_URL ?? 'https://api.utopia-lab.org'),
+ validateInviteFlowId: String(
+ import.meta.env.VITE_VALIDATE_INVITE_FLOW_ID ?? '01d61db0-25aa-4bfa-bc24-c6a8f208a455',
+ ),
+ redeemInviteFlowId: String(
+ import.meta.env.VITE_REDEEM_INVITE_FLOW_ID ?? 'cc80ec73-ecf5-4789-bee5-1127fb1a6ed4',
+ ),
+ openCollectiveApiKey: String(import.meta.env.VITE_OPEN_COLLECTIVE_API_KEY ?? ''),
+}
+
+export type Config = typeof config
diff --git a/app/tsconfig.json b/app/tsconfig.json
index 5469587c..95a0cf49 100644
--- a/app/tsconfig.json
+++ b/app/tsconfig.json
@@ -10,16 +10,40 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
+ "baseUrl": ".",
"paths": {
- "utopia-ui": ["../lib/src"],
- "#components/*": ["../lib/src/Components/*"],
- "#utils/*": ["../lib/src/Utils/*"],
- "#types/*": ["../lib/src/types/*"],
- "#assets/*": ["../lib/src/assets/*"],
- "#src/*": ["../lib/src/*"],
- "#root/*": ["../lib/*"]
+ "@/*": [
+ "src/*"
+ ],
+ "utopia-ui": [
+ "../lib/src"
+ ],
+ "#components/*": [
+ "../lib/src/Components/*"
+ ],
+ "#utils/*": [
+ "../lib/src/Utils/*"
+ ],
+ "#types/*": [
+ "../lib/src/types/*"
+ ],
+ "#assets/*": [
+ "../lib/src/assets/*"
+ ],
+ "#src/*": [
+ "../lib/src/*"
+ ],
+ "#root/*": [
+ "../lib/*"
+ ]
}
},
- "include": ["src"],
- "references": [{ "path": "./tsconfig.node.json" }]
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "./tsconfig.node.json"
+ }
+ ]
}
diff --git a/app/vite.config.ts b/app/vite.config.ts
index 0bb7f880..2be6ed48 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -1,11 +1,12 @@
-import { defineConfig } from 'vite';
-import react from '@vitejs/plugin-react';
-import tailwindcss from '@tailwindcss/vite';
-import fs from 'fs';
-import path from 'path';
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+import fs from 'fs'
+import path from 'path'
+import tsConfigPaths from 'vite-tsconfig-paths'
// __dirname-Ersatz für ESModules
-const __dirname = path.dirname(new URL(import.meta.url).pathname);
+const __dirname = path.dirname(new URL(import.meta.url).pathname)
export default defineConfig({
server: {
@@ -18,21 +19,9 @@ export default defineConfig({
* },
*/
},
- plugins: [
- react(),
- tailwindcss(),
- ],
+ plugins: [react(), tailwindcss(), tsConfigPaths()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom'],
- alias: {
- 'utopia-ui': path.resolve(__dirname, '../lib/src'),
- '#components': path.resolve(__dirname, '../lib/src/Components'),
- '#utils': path.resolve(__dirname, '../lib/src/Utils'),
- '#types': path.resolve(__dirname, '../lib/src/types'),
- '#assets': path.resolve(__dirname, '../lib/src/assets'),
- '#src': path.resolve(__dirname, '../lib/src'),
- '#root': path.resolve(__dirname, '../lib'),
- }
},
build: {
sourcemap: true,
@@ -44,21 +33,20 @@ export default defineConfig({
}
if (id.includes('node_modules')) {
if (id.includes('react')) {
- return 'react';
+ return 'react'
}
if (id.includes('tiptap')) {
- return 'tiptap';
+ return 'tiptap'
}
if (id.includes('leaflet')) {
- return 'leaflet';
+ return 'leaflet'
}
if (id.includes('lib/node_modules')) {
return 'utopia-ui-vendor'
- }
- else return 'vendor';
+ } else return 'vendor'
}
- }
+ },
},
},
},
-});
+})
diff --git a/lib/package-lock.json b/lib/package-lock.json
index d6cd9828..4dbba735 100644
--- a/lib/package-lock.json
+++ b/lib/package-lock.json
@@ -37,6 +37,7 @@
"react-leaflet-cluster": "^2.1.0",
"react-markdown": "^9.0.1",
"react-photo-album": "^3.0.2",
+ "react-qr-code": "^2.0.16",
"react-router-dom": "^6.23.0",
"react-toastify": "^9.1.3",
"remark-breaks": "^4.0.0",
@@ -11103,6 +11104,12 @@
"node": ">=6"
}
},
+ "node_modules/qr.js": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz",
+ "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==",
+ "license": "MIT"
+ },
"node_modules/qs": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz",
@@ -11319,6 +11326,19 @@
}
}
},
+ "node_modules/react-qr-code": {
+ "version": "2.0.16",
+ "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.16.tgz",
+ "integrity": "sha512-8f54aTOo7DxYr1LB47pMeclV5SL/zSbJxkXHIS2a+QnAIa4XDVIdmzYRC+CBCJeDLSCeFHn8gHtltwvwZGJD/w==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.8.1",
+ "qr.js": "0.0.0"
+ },
+ "peerDependencies": {
+ "react": "*"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
diff --git a/lib/package.json b/lib/package.json
index 0601b494..0a1fe6bc 100644
--- a/lib/package.json
+++ b/lib/package.json
@@ -125,6 +125,7 @@
"react-leaflet-cluster": "^2.1.0",
"react-markdown": "^9.0.1",
"react-photo-album": "^3.0.2",
+ "react-qr-code": "^2.0.16",
"react-router-dom": "^6.23.0",
"react-toastify": "^9.1.3",
"remark-breaks": "^4.0.0",
diff --git a/lib/rollup.config.js b/lib/rollup.config.js
index a68ba830..25e90725 100644
--- a/lib/rollup.config.js
+++ b/lib/rollup.config.js
@@ -47,6 +47,7 @@ export default [
/node_modules\/tiptap-markdown/,
/node_modules\/markdown-it-task-lists/,
/node_modules\/classnames/,
+ /node_modules\/react-qr-code/,
],
requireReturnsDefault: 'auto',
}),
diff --git a/lib/src/Components/Auth/LoginPage.tsx b/lib/src/Components/Auth/LoginPage.tsx
index b3a835a1..a87bdd01 100644
--- a/lib/src/Components/Auth/LoginPage.tsx
+++ b/lib/src/Components/Auth/LoginPage.tsx
@@ -1,28 +1,65 @@
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
+import { useMyProfile } from '#components/Map/hooks/useMyProfile'
import { MapOverlayPage } from '#components/Templates/MapOverlayPage'
import { useAuth } from './useAuth'
+import type { InviteApi } from '#types/InviteApi'
+
+interface Props {
+ inviteApi: InviteApi
+}
+
/**
* @category Auth
*/
-export function LoginPage() {
+export function LoginPage({ inviteApi }: Props) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const { login, loading } = useAuth()
+ const { myProfile, isMyProfileLoaded } = useMyProfile()
+
const navigate = useNavigate()
- // eslint-disable-next-line react-hooks/exhaustive-deps
- const onLogin = async () => {
+ const redeemInvite = useCallback(
+ async (inviteCode: string): Promise => {
+ if (!isMyProfileLoaded) return null
+
+ if (!myProfile) {
+ toast.error('Could not find your profile to redeem the invite.')
+ return null
+ }
+
+ const invitingProfileId = await inviteApi.redeemInvite(inviteCode, myProfile.id)
+ localStorage.removeItem('inviteCode') // Clear invite code after redeeming
+ return invitingProfileId
+ },
+ [inviteApi, isMyProfileLoaded, myProfile],
+ )
+
+ const handleSuccess = useCallback(async () => {
+ const inviteCode = localStorage.getItem('inviteCode')
+ let invitingProfileId: string | null = null
+ if (inviteCode) {
+ invitingProfileId = await redeemInvite(inviteCode)
+ }
+ if (invitingProfileId) {
+ navigate(`/item/${invitingProfileId}`)
+ } else {
+ navigate('/')
+ }
+ }, [navigate, redeemInvite])
+
+ const onLogin = useCallback(async () => {
await toast.promise(login({ email, password }), {
success: {
render({ data }) {
- navigate('/')
+ void handleSuccess()
return `Hi ${data?.first_name ? data.first_name : 'Traveler'}`
},
// other options
@@ -36,7 +73,7 @@ export function LoginPage() {
},
pending: 'logging in ...',
})
- }
+ }, [email, handleSuccess, login, password])
useEffect(() => {
const keyDownHandler = (event: KeyboardEvent) => {
diff --git a/lib/src/Components/Auth/useAuth.tsx b/lib/src/Components/Auth/useAuth.tsx
index 83c416b0..413e88f5 100644
--- a/lib/src/Components/Auth/useAuth.tsx
+++ b/lib/src/Components/Auth/useAuth.tsx
@@ -1,10 +1,12 @@
-import { createContext, useState, useContext, useEffect } from 'react'
+import { createContext, useState, useContext, useEffect, useCallback } from 'react'
+import type { InviteApi } from '#types/InviteApi'
import type { UserApi } from '#types/UserApi'
import type { UserItem } from '#types/UserItem'
interface AuthProviderProps {
userApi: UserApi
+ inviteApi: InviteApi
children?: React.ReactNode
}
@@ -16,6 +18,7 @@ interface AuthCredentials {
interface AuthContextProps {
isAuthenticated: boolean
+ isInitialized: boolean
user: UserItem | null
login: (credentials: AuthCredentials) => Promise
register: (credentials: AuthCredentials, userName: string) => Promise
@@ -29,6 +32,7 @@ interface AuthContextProps {
const AuthContext = createContext({
isAuthenticated: false,
+ isInitialized: false,
user: null,
login: () => Promise.reject(Error('Unimplemented')),
register: () => Promise.reject(Error('Unimplemented')),
@@ -47,17 +51,10 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
const [user, setUser] = useState(null)
const [token, setToken] = useState()
const [loading, setLoading] = useState(false)
+ const [isInitialized, setIsInitialized] = useState(false)
const isAuthenticated = !!user
- useEffect(() => {
- setLoading(true)
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
- loadUser()
- setLoading(false)
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
-
- async function loadUser(): Promise {
+ const loadUser: () => Promise = useCallback(async () => {
try {
const token = await userApi.getToken()
setToken(token)
@@ -66,20 +63,30 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
setUser(me)
setLoading(false)
return me
- } else return undefined
+ } else {
+ return undefined
+ }
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
setLoading(false)
return undefined
+ } finally {
+ setIsInitialized(true)
}
- }
+ }, [userApi])
+
+ useEffect(() => {
+ void loadUser()
+ }, [loadUser])
const login = async (credentials: AuthCredentials): Promise => {
setLoading(true)
try {
const user = await userApi.login(credentials.email, credentials.password)
setToken(user?.access_token)
- return await loadUser()
+ const fullUser = await loadUser()
+
+ return fullUser
} catch (error) {
setLoading(false)
throw error
@@ -150,6 +157,7 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
{
+ const items = useItems()
+ const allItemsLoaded = useAllItemsLoaded()
+
+ const user = useAuth().user
+
+ // allItemsLoaded is not reliable, so we check if items.length > 0
+ const isMyProfileLoaded = allItemsLoaded && items.length > 0 && !!user
+
+ // Find the user's profile item
+ const myProfile = items.find(
+ (item) => item.layer?.userProfileLayer && item.user_created?.id === user?.id,
+ )
+
+ return { myProfile, isMyProfileLoaded }
+}
diff --git a/lib/src/Components/Onboarding/InvitePage.tsx b/lib/src/Components/Onboarding/InvitePage.tsx
new file mode 100644
index 00000000..800766a8
--- /dev/null
+++ b/lib/src/Components/Onboarding/InvitePage.tsx
@@ -0,0 +1,75 @@
+import { useEffect } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { toast } from 'react-toastify'
+
+import { useAuth } from '#components/Auth/useAuth'
+import { useMyProfile } from '#components/Map/hooks/useMyProfile'
+import { MapOverlayPage } from '#components/Templates/MapOverlayPage'
+
+import type { InviteApi } from '#types/InviteApi'
+
+interface Props {
+ inviteApi: InviteApi
+}
+
+/**
+ * @category Onboarding
+ */
+export function InvitePage({ inviteApi }: Props) {
+ const { isAuthenticated, isInitialized: isAuthenticationInitialized } = useAuth()
+ const { id } = useParams<{ id: string }>()
+ const navigate = useNavigate()
+
+ const { myProfile, isMyProfileLoaded } = useMyProfile()
+
+ if (!id) throw new Error('Invite ID is required')
+
+ useEffect(() => {
+ async function redeemInvite() {
+ if (!id) throw new Error('Invite ID is required')
+
+ if (!isMyProfileLoaded) return
+
+ if (!myProfile) {
+ toast.error('Could not find your profile to redeem the invite.')
+ return
+ }
+
+ const invitingProfileId = await inviteApi.redeemInvite(id, myProfile.id)
+
+ if (invitingProfileId) {
+ toast.success('Invite redeemed successfully!')
+ navigate(`/item/${invitingProfileId}`)
+ } else {
+ toast.error('Failed to redeem invite')
+ navigate('/')
+ }
+ }
+
+ if (!isAuthenticationInitialized) return
+
+ if (isAuthenticated) {
+ void redeemInvite()
+ } else {
+ // Save invite code in local storage
+ localStorage.setItem('inviteCode', id)
+
+ // Redirect to login page
+ navigate('/login')
+ }
+ }, [
+ id,
+ isAuthenticated,
+ inviteApi,
+ navigate,
+ isAuthenticationInitialized,
+ myProfile,
+ isMyProfileLoaded,
+ ])
+
+ return (
+
+ Invitation
+
+ )
+}
diff --git a/lib/src/Components/Onboarding/index.ts b/lib/src/Components/Onboarding/index.ts
new file mode 100644
index 00000000..852a6f18
--- /dev/null
+++ b/lib/src/Components/Onboarding/index.ts
@@ -0,0 +1 @@
+export { InvitePage } from './InvitePage'
diff --git a/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx b/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx
new file mode 100644
index 00000000..ece8b690
--- /dev/null
+++ b/lib/src/Components/Profile/Subcomponents/InviteLinkView.spec.tsx
@@ -0,0 +1,75 @@
+import { render, fireEvent } from '@testing-library/react'
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+
+import { InviteLinkView } from './InviteLinkView'
+
+import type { Item } from '#types/Item'
+
+const itemWithSecret: Item = {
+ secrets: [
+ {
+ secret: 'secret1',
+ },
+ ],
+ id: '1',
+ name: 'Test Item',
+}
+
+const itemWithoutSecret: Item = {
+ secrets: [],
+ id: '2',
+ name: 'Test Item Without Secret',
+}
+
+const itemWithUndefinedSecrets: Item = {
+ id: '3',
+ name: 'Test Item With Undefined Secrets',
+}
+
+describe('', () => {
+ let wrapper: ReturnType
+
+ const Wrapper = ({ item }: { item: Item }) => {
+ return render()
+ }
+
+ describe('when item does not have secrets', () => {
+ it('does not render anything', () => {
+ wrapper = Wrapper({ item: itemWithoutSecret })
+ expect(wrapper.container.firstChild).toBeNull()
+ })
+ })
+
+ describe('when item has secrets undefined', () => {
+ it('does not render anything', () => {
+ wrapper = Wrapper({ item: itemWithUndefinedSecrets })
+ expect(wrapper.container.firstChild).toBeNull()
+ })
+ })
+
+ describe('when item has secrets', () => {
+ beforeEach(() => {
+ wrapper = Wrapper({ item: itemWithSecret })
+ })
+
+ it('renders the secret', () => {
+ expect(wrapper.getByDisplayValue('secret1', { exact: false })).toBeInTheDocument()
+ })
+
+ it('matches the snapshot', () => {
+ expect(wrapper.container.firstChild).toMatchSnapshot()
+ })
+
+ it('copies the secret to clipboard when button is clicked', () => {
+ const copyButton = wrapper.getByRole('button')
+ expect(copyButton).toBeInTheDocument()
+
+ const clipboardSpy = vi.spyOn(navigator.clipboard, 'writeText')
+
+ fireEvent.click(copyButton)
+
+ // TODO Implement in a way that the URL stays consistent on CI
+ expect(clipboardSpy).toHaveBeenCalledWith('http://localhost:3000/invite/secret1')
+ })
+ })
+})
diff --git a/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx b/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx
new file mode 100644
index 00000000..34bf615b
--- /dev/null
+++ b/lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx
@@ -0,0 +1,38 @@
+import { ClipboardIcon } from '@heroicons/react/24/outline'
+import QRCode from 'react-qr-code'
+import { toast } from 'react-toastify'
+
+import type { Item } from '#types/Item'
+
+export const InviteLinkView = ({ item }: { item: Item }) => {
+ // Only show if user has permission to view secrets.
+ if (!item.secrets || item.secrets.length === 0) return
+
+ const link = `${window.location.origin}/invite/${item.secrets[0].secret}`
+
+ const copyToClipboard = () => {
+ void navigator.clipboard
+ .writeText(link)
+ .then(() => toast.success('Invite link copied to clipboard!'))
+ }
+
+ return (
+
+
Invite
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/lib/src/Components/Profile/Subcomponents/RelationsView.tsx b/lib/src/Components/Profile/Subcomponents/RelationsView.tsx
new file mode 100644
index 00000000..2348b646
--- /dev/null
+++ b/lib/src/Components/Profile/Subcomponents/RelationsView.tsx
@@ -0,0 +1,37 @@
+import { useItems } from '#components/Map/hooks/useItems'
+
+import type { Item } from '#types/Item'
+
+interface Props {
+ item: Item
+ relation: string
+}
+
+export const RelationsView = ({ item, relation }: Props) => {
+ const items = useItems()
+
+ if (!item.relations) return
+
+ const relationsOfRightType = item.relations.filter((r) => r.type === relation)
+
+ const relatedItems = items.filter((i) => relationsOfRightType.some((r) => r.id === i.id))
+
+ const hasRelatedItems = relatedItems.length > 0
+
+ return (
+
+
{relation}
+ {hasRelatedItems ? (
+
+ ) : (
+
No related items found.
+ )}
+
+ )
+}
diff --git a/lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap b/lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap
new file mode 100644
index 00000000..0340551f
--- /dev/null
+++ b/lib/src/Components/Profile/Subcomponents/__snapshots__/InviteLinkView.spec.tsx.snap
@@ -0,0 +1,62 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[` > when item has secrets > matches the snapshot 1`] = `
+
+`;
diff --git a/lib/src/Components/Profile/Templates/FlexForm.tsx b/lib/src/Components/Profile/Templates/FlexForm.tsx
index 87a5dca1..59406070 100644
--- a/lib/src/Components/Profile/Templates/FlexForm.tsx
+++ b/lib/src/Components/Profile/Templates/FlexForm.tsx
@@ -17,6 +17,7 @@ const componentMap = {
startEnd: ProfileStartEndForm,
crowdfundings: CrowdfundingForm,
gallery: GalleryForm,
+ inviteLinks: () => null, // Not needed for now
// weitere Komponenten hier
}
diff --git a/lib/src/Components/Profile/Templates/FlexView.tsx b/lib/src/Components/Profile/Templates/FlexView.tsx
index 845898c3..e3024c45 100644
--- a/lib/src/Components/Profile/Templates/FlexView.tsx
+++ b/lib/src/Components/Profile/Templates/FlexView.tsx
@@ -4,8 +4,10 @@ import { ContactInfoView } from '#components/Profile/Subcomponents/ContactInfoVi
import { CrowdfundingView } from '#components/Profile/Subcomponents/CrowdfundingView'
import { GalleryView } from '#components/Profile/Subcomponents/GalleryView'
import { GroupSubHeaderView } from '#components/Profile/Subcomponents/GroupSubHeaderView'
+import { InviteLinkView } from '#components/Profile/Subcomponents/InviteLinkView'
import { ProfileStartEndView } from '#components/Profile/Subcomponents/ProfileStartEndView'
import { ProfileTextView } from '#components/Profile/Subcomponents/ProfileTextView'
+import { RelationsView } from '#components/Profile/Subcomponents/RelationsView'
import type { Item } from '#types/Item'
import type { Key } from 'react'
@@ -17,6 +19,8 @@ const componentMap = {
startEnd: ProfileStartEndView,
gallery: GalleryView,
crowdfundings: CrowdfundingView,
+ inviteLinks: InviteLinkView,
+ relations: RelationsView,
// weitere Komponenten hier
}
diff --git a/lib/src/index.tsx b/lib/src/index.tsx
index 8a9d5e89..f3239c11 100644
--- a/lib/src/index.tsx
+++ b/lib/src/index.tsx
@@ -8,6 +8,7 @@ export * from './Components/Gaming'
export * from './Components/Templates'
export * from './Components/Input'
export * from './Components/Item'
+export * from './Components/Onboarding'
export * from './Components/Profile'
declare global {
diff --git a/lib/src/types/InviteApi.d.ts b/lib/src/types/InviteApi.d.ts
new file mode 100644
index 00000000..a4a335c6
--- /dev/null
+++ b/lib/src/types/InviteApi.d.ts
@@ -0,0 +1,4 @@
+export interface InviteApi {
+ validateInvite(inviteId: string): Promise
+ redeemInvite(inviteId: string, itemId: string): Promise
+}
diff --git a/lib/src/types/Item.d.ts b/lib/src/types/Item.d.ts
index 7dd9dcf1..12d4b8e2 100644
--- a/lib/src/types/Item.d.ts
+++ b/lib/src/types/Item.d.ts
@@ -18,6 +18,10 @@ interface GalleryItem {
| string
}
+interface ItemSecret {
+ secret: string
+}
+
/**
* @category Types
*/
@@ -55,6 +59,7 @@ export interface Item {
next_appointment?: string
gallery?: GalleryItem[]
openCollectiveSlug?: string
+ secrets?: ItemSecret[]
// {
// coordinates: [number, number]
From 7e0d44dac801a38252bc47d906bdb638f29e0acc Mon Sep 17 00:00:00 2001
From: Anton Tranelis <31516529+antontranelis@users.noreply.github.com>
Date: Wed, 16 Jul 2025 20:57:21 +0200
Subject: [PATCH 4/4] fix(app): add close button to custom info modal (#275)
* add close button to custom info modal
* update workflow
* fixes workflow
---------
Co-authored-by: Ulf Gebhardt
---
.github/workflows/test.build.lib.yml | 8 +++++++-
app/src/ModalContent.tsx | 8 ++++++++
2 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/.github/workflows/test.build.lib.yml b/.github/workflows/test.build.lib.yml
index 99b3cd50..f47d03b2 100644
--- a/.github/workflows/test.build.lib.yml
+++ b/.github/workflows/test.build.lib.yml
@@ -1,6 +1,12 @@
name: build:lib
-on: push
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
jobs:
files-changed:
diff --git a/app/src/ModalContent.tsx b/app/src/ModalContent.tsx
index 61ba2a6e..65e849dc 100644
--- a/app/src/ModalContent.tsx
+++ b/app/src/ModalContent.tsx
@@ -17,6 +17,14 @@ export function Welcome1({ clickAction1, map }: ChapterProps) {
{map.custom_text ? (
<>
+
+
+
>
) : (
<>