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 <mail@antontranelis.de>
This commit is contained in:
Max 2025-07-11 13:37:05 +02:00 committed by GitHub
parent 855ef3de29
commit 1e7320b895
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 623 additions and 65 deletions

View File

@ -1 +1,4 @@
VITE_OPEN_COLLECTIVE_API_KEY=your_key
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

View File

@ -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',

1
app/.gitignore vendored
View File

@ -1,2 +1,3 @@
node_modules/
dist/
.DS_Store

50
app/package-lock.json generated
View File

@ -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",

View File

@ -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": {

View File

@ -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<permissionsApi>()
@ -140,12 +146,12 @@ function App() {
if (map && layers)
return (
<div className='App tw:overflow-x-hidden'>
<AuthProvider userApi={new userApi()}>
<AuthProvider userApi={userApi} inviteApi={inviteApi}>
<AppShell
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}
>
<Permissions
api={permissionsApiInstance}
@ -160,7 +166,8 @@ function App() {
<Quests />
<Routes>
<Route path='/*' element={<MapContainer map={map} layers={layers} />}>
<Route path='login' element={<LoginPage />} />
<Route path='invite/:id' element={<InvitePage inviteApi={inviteApi} />} />
<Route path='login' element={<LoginPage inviteApi={inviteApi} />} />
<Route path='signup' element={<SignupPage />} />
<Route
path='reset-password'

View File

@ -51,6 +51,11 @@ interface CustomUserFields {
position: Point
}
interface ItemSecret {
secret: string
item: string
}
export interface MyCollections {
places: Place[]
events: Event[]
@ -58,6 +63,7 @@ export interface MyCollections {
tags: Tag[]
projects: Project[]
directus_users: CustomUserFields[]
item_secrets: ItemSecret[]
items: Item[]
team: any[]
features: any[]

79
app/src/api/inviteApi.ts Normal file
View File

@ -0,0 +1,79 @@
/* @eslint-disable-next-line import/no-relative-parent-imports */
import { config } from '@/config'
import type { UserApi } from 'utopia-ui'
type InvitingProfileResponse = [
{
item: string
},
]
export class InviteApi {
userApi: UserApi
constructor(userApi: UserApi) {
this.userApi = userApi
}
async validateInvite(inviteId: string): Promise<string | null> {
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<string | null> {
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.')
}
}
}
}

View File

@ -45,6 +45,7 @@ export class itemsApi<T> implements ItemsApi<T> {
readItems(this.collectionName as never, {
fields: [
'*',
'secrets.*',
'to.*',
'relations.*',
'user_created.*',

View File

@ -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<any> {
try {
return await directusClient.request(createUser({ email, password, first_name: userName }))

12
app/src/config/index.ts Normal file
View File

@ -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

View File

@ -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"
}
]
}

View File

@ -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'
}
}
},
},
},
},
});
})

20
lib/package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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',
}),

View File

@ -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<string>('')
const [password, setPassword] = useState<string>('')
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<string | null> => {
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) => {

View File

@ -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<UserItem | undefined>
register: (credentials: AuthCredentials, userName: string) => Promise<UserItem | undefined>
@ -29,6 +32,7 @@ interface AuthContextProps {
const AuthContext = createContext<AuthContextProps>({
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<UserItem | null>(null)
const [token, setToken] = useState<string>()
const [loading, setLoading] = useState<boolean>(false)
const [isInitialized, setIsInitialized] = useState<boolean>(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<UserItem | undefined> {
const loadUser: () => Promise<UserItem | undefined> = 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<UserItem | undefined> => {
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) => {
<AuthContext.Provider
value={{
isAuthenticated,
isInitialized,
user,
login,
register,

View File

@ -0,0 +1,20 @@
import { useAuth } from '#components/Auth/useAuth'
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, isMyProfileLoaded }
}

View File

@ -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 (
<MapOverlayPage backdrop className='tw:max-w-xs tw:h-fit'>
<h2 className='tw:text-2xl tw:font-semibold tw:mb-2 tw:text-center'>Invitation</h2>
</MapOverlayPage>
)
}

View File

@ -0,0 +1 @@
export { InvitePage } from './InvitePage'

View File

@ -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('<InviteLinkView />', () => {
let wrapper: ReturnType<typeof render>
const Wrapper = ({ item }: { item: Item }) => {
return render(<InviteLinkView item={item} />)
}
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')
})
})
})

View File

@ -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 (
<div className='tw:my-10 tw:mt-2 tw:px-6'>
<h2 className='tw:text-lg tw:font-semibold'>Invite</h2>
<div className='tw:mt-2 tw:text-sm tw:flex tw:gap-2 tw:mb-2'>
<input
type='text'
value={link}
readOnly
className='tw:w-full tw:p-2 tw:border tw:rounded'
/>
<button onClick={copyToClipboard} className='btn btn-circle btn-primary'>
<ClipboardIcon className='w-6 h-6' />
</button>
</div>
<div className='tw:bg-white tw:p-2 tw:w-fit'>
<QRCode value={link} size={128} />
</div>
</div>
)
}

View File

@ -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 (
<div>
<h2>{relation}</h2>
{hasRelatedItems ? (
<ul>
{relatedItems.map((relatedItem) => (
<li key={relatedItem.id}>
<a href={`/item/${relatedItem.id}`}>{relatedItem.name}</a>
</li>
))}
</ul>
) : (
<p>No related items found.</p>
)}
</div>
)
}

File diff suppressed because one or more lines are too long

View File

@ -17,6 +17,7 @@ const componentMap = {
startEnd: ProfileStartEndForm,
crowdfundings: CrowdfundingForm,
gallery: GalleryForm,
inviteLinks: () => null, // Not needed for now
// weitere Komponenten hier
}

View File

@ -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
}

View File

@ -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 {

4
lib/src/types/InviteApi.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
export interface InviteApi {
validateInvite(inviteId: string): Promise<string | null>
redeemInvite(inviteId: string, itemId: string): Promise<string | null>
}

View File

@ -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]