mirror of
https://github.com/utopia-os/utopia-ui.git
synced 2025-12-13 07:46:10 +00:00
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:
parent
855ef3de29
commit
1e7320b895
5
app/.env
5
app/.env
@ -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
|
||||||
|
|||||||
@ -82,7 +82,7 @@ module.exports = {
|
|||||||
'import/no-relative-parent-imports': [
|
'import/no-relative-parent-imports': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
ignore: ['#[src,types,root,components,utils,assets]/*'],
|
ignore: ['#[src,types,root,components,utils,assets]/*', '@/config/*'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'import/no-self-import': 'error',
|
'import/no-self-import': 'error',
|
||||||
|
|||||||
1
app/.gitignore
vendored
1
app/.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
.DS_Store
|
||||||
|
|||||||
50
app/package-lock.json
generated
50
app/package-lock.json
generated
@ -18,7 +18,8 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"react-router-dom": "^6.23.0",
|
"react-router-dom": "^6.23.0",
|
||||||
"utopia-ui": "^3.0.111"
|
"utopia-ui": "^3.0.111",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.1",
|
"@eslint-community/eslint-plugin-eslint-comments": "^4.4.1",
|
||||||
@ -6740,6 +6741,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@ -10984,6 +10991,26 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"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": {
|
"node_modules/tsconfig-paths": {
|
||||||
"version": "3.15.0",
|
"version": "3.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz",
|
||||||
@ -11147,7 +11174,7 @@
|
|||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"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": {
|
"node_modules/vite/node_modules/fdir": {
|
||||||
"version": "6.4.4",
|
"version": "6.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-rnd": "^10.4.1",
|
"react-rnd": "^10.4.1",
|
||||||
"react-router-dom": "^6.23.0",
|
"react-router-dom": "^6.23.0",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4",
|
||||||
"utopia-ui": "^3.0.111"
|
"utopia-ui": "^3.0.111"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import {
|
|||||||
Content,
|
Content,
|
||||||
AuthProvider,
|
AuthProvider,
|
||||||
Modal,
|
Modal,
|
||||||
|
InvitePage,
|
||||||
LoginPage,
|
LoginPage,
|
||||||
SignupPage,
|
SignupPage,
|
||||||
Quests,
|
Quests,
|
||||||
@ -33,8 +34,8 @@ import {
|
|||||||
MarketView,
|
MarketView,
|
||||||
SVG,
|
SVG,
|
||||||
LoadingMapOverlay,
|
LoadingMapOverlay,
|
||||||
ProfileView,
|
|
||||||
ProfileForm,
|
ProfileForm,
|
||||||
|
ProfileView,
|
||||||
UserSettings,
|
UserSettings,
|
||||||
} from 'utopia-ui'
|
} from 'utopia-ui'
|
||||||
|
|
||||||
@ -48,11 +49,16 @@ import { itemsApi } from './api/itemsApi'
|
|||||||
import { layersApi } from './api/layersApi'
|
import { layersApi } from './api/layersApi'
|
||||||
import { mapApi } from './api/mapApi'
|
import { mapApi } from './api/mapApi'
|
||||||
import { permissionsApi } from './api/permissionsApi'
|
import { permissionsApi } from './api/permissionsApi'
|
||||||
import { userApi } from './api/userApi'
|
import { UserApi } from './api/userApi'
|
||||||
import { ModalContent } from './ModalContent'
|
import { ModalContent } from './ModalContent'
|
||||||
import { Landingpage } from './pages/Landingpage'
|
import { Landingpage } from './pages/Landingpage'
|
||||||
import MapContainer from './pages/MapContainer'
|
import MapContainer from './pages/MapContainer'
|
||||||
import { getBottomRoutes, routes } from './routes/sidebar'
|
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() {
|
function App() {
|
||||||
const [permissionsApiInstance, setPermissionsApiInstance] = useState<permissionsApi>()
|
const [permissionsApiInstance, setPermissionsApiInstance] = useState<permissionsApi>()
|
||||||
@ -140,12 +146,12 @@ function App() {
|
|||||||
if (map && layers)
|
if (map && layers)
|
||||||
return (
|
return (
|
||||||
<div className='App tw:overflow-x-hidden'>
|
<div className='App tw:overflow-x-hidden'>
|
||||||
<AuthProvider userApi={new userApi()}>
|
<AuthProvider userApi={userApi} inviteApi={inviteApi}>
|
||||||
<AppShell
|
<AppShell
|
||||||
assetsApi={new assetsApi('https://api.utopia-lab.org/assets/')}
|
assetsApi={new assetsApi('https://api.utopia-lab.org/assets/')}
|
||||||
appName={map.name}
|
appName={map.name}
|
||||||
embedded={embedded}
|
embedded={embedded}
|
||||||
openCollectiveApiKey={import.meta.env.VITE_OPEN_COLLECTIVE_API_KEY}
|
openCollectiveApiKey={config.openCollectiveApiKey}
|
||||||
>
|
>
|
||||||
<Permissions
|
<Permissions
|
||||||
api={permissionsApiInstance}
|
api={permissionsApiInstance}
|
||||||
@ -160,7 +166,8 @@ function App() {
|
|||||||
<Quests />
|
<Quests />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path='/*' element={<MapContainer map={map} layers={layers} />}>
|
<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='signup' element={<SignupPage />} />
|
||||||
<Route
|
<Route
|
||||||
path='reset-password'
|
path='reset-password'
|
||||||
|
|||||||
@ -51,6 +51,11 @@ interface CustomUserFields {
|
|||||||
position: Point
|
position: Point
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ItemSecret {
|
||||||
|
secret: string
|
||||||
|
item: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface MyCollections {
|
export interface MyCollections {
|
||||||
places: Place[]
|
places: Place[]
|
||||||
events: Event[]
|
events: Event[]
|
||||||
@ -58,6 +63,7 @@ export interface MyCollections {
|
|||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
projects: Project[]
|
projects: Project[]
|
||||||
directus_users: CustomUserFields[]
|
directus_users: CustomUserFields[]
|
||||||
|
item_secrets: ItemSecret[]
|
||||||
items: Item[]
|
items: Item[]
|
||||||
team: any[]
|
team: any[]
|
||||||
features: any[]
|
features: any[]
|
||||||
|
|||||||
79
app/src/api/inviteApi.ts
Normal file
79
app/src/api/inviteApi.ts
Normal 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.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -45,6 +45,7 @@ export class itemsApi<T> implements ItemsApi<T> {
|
|||||||
readItems(this.collectionName as never, {
|
readItems(this.collectionName as never, {
|
||||||
fields: [
|
fields: [
|
||||||
'*',
|
'*',
|
||||||
|
'secrets.*',
|
||||||
'to.*',
|
'to.*',
|
||||||
'relations.*',
|
'relations.*',
|
||||||
'user_created.*',
|
'user_created.*',
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { createUser, passwordRequest, passwordReset, readMe, updateMe } from '@d
|
|||||||
|
|
||||||
import { directusClient } from './directus'
|
import { directusClient } from './directus'
|
||||||
|
|
||||||
import type { UserApi, UserItem } from 'utopia-ui'
|
import type { UserItem } from 'utopia-ui'
|
||||||
|
|
||||||
interface DirectusError {
|
interface DirectusError {
|
||||||
errors: {
|
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> {
|
async register(email: string, password: string, userName: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
return await directusClient.request(createUser({ email, password, first_name: userName }))
|
return await directusClient.request(createUser({ email, password, first_name: userName }))
|
||||||
|
|||||||
12
app/src/config/index.ts
Normal file
12
app/src/config/index.ts
Normal 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
|
||||||
@ -10,16 +10,40 @@
|
|||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"utopia-ui": ["../lib/src"],
|
"@/*": [
|
||||||
"#components/*": ["../lib/src/Components/*"],
|
"src/*"
|
||||||
"#utils/*": ["../lib/src/Utils/*"],
|
],
|
||||||
"#types/*": ["../lib/src/types/*"],
|
"utopia-ui": [
|
||||||
"#assets/*": ["../lib/src/assets/*"],
|
"../lib/src"
|
||||||
"#src/*": ["../lib/src/*"],
|
],
|
||||||
"#root/*": ["../lib/*"]
|
"#components/*": [
|
||||||
|
"../lib/src/Components/*"
|
||||||
|
],
|
||||||
|
"#utils/*": [
|
||||||
|
"../lib/src/Utils/*"
|
||||||
|
],
|
||||||
|
"#types/*": [
|
||||||
|
"../lib/src/types/*"
|
||||||
|
],
|
||||||
|
"#assets/*": [
|
||||||
|
"../lib/src/assets/*"
|
||||||
|
],
|
||||||
|
"#src/*": [
|
||||||
|
"../lib/src/*"
|
||||||
|
],
|
||||||
|
"#root/*": [
|
||||||
|
"../lib/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src"],
|
"include": [
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
"src"
|
||||||
|
],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import fs from 'fs';
|
import fs from 'fs'
|
||||||
import path from 'path';
|
import path from 'path'
|
||||||
|
import tsConfigPaths from 'vite-tsconfig-paths'
|
||||||
|
|
||||||
// __dirname-Ersatz für ESModules
|
// __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({
|
export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
@ -18,21 +19,9 @@ export default defineConfig({
|
|||||||
* },
|
* },
|
||||||
*/
|
*/
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [react(), tailwindcss(), tsConfigPaths()],
|
||||||
react(),
|
|
||||||
tailwindcss(),
|
|
||||||
],
|
|
||||||
resolve: {
|
resolve: {
|
||||||
dedupe: ['react', 'react-dom', 'react-router-dom'],
|
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: {
|
build: {
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
@ -44,21 +33,20 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
if (id.includes('node_modules')) {
|
if (id.includes('node_modules')) {
|
||||||
if (id.includes('react')) {
|
if (id.includes('react')) {
|
||||||
return 'react';
|
return 'react'
|
||||||
}
|
}
|
||||||
if (id.includes('tiptap')) {
|
if (id.includes('tiptap')) {
|
||||||
return 'tiptap';
|
return 'tiptap'
|
||||||
}
|
}
|
||||||
if (id.includes('leaflet')) {
|
if (id.includes('leaflet')) {
|
||||||
return 'leaflet';
|
return 'leaflet'
|
||||||
}
|
}
|
||||||
if (id.includes('lib/node_modules')) {
|
if (id.includes('lib/node_modules')) {
|
||||||
return 'utopia-ui-vendor'
|
return 'utopia-ui-vendor'
|
||||||
}
|
} else return 'vendor'
|
||||||
else return 'vendor';
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
|
|||||||
20
lib/package-lock.json
generated
20
lib/package-lock.json
generated
@ -37,6 +37,7 @@
|
|||||||
"react-leaflet-cluster": "^2.1.0",
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-photo-album": "^3.0.2",
|
"react-photo-album": "^3.0.2",
|
||||||
|
"react-qr-code": "^2.0.16",
|
||||||
"react-router-dom": "^6.23.0",
|
"react-router-dom": "^6.23.0",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
@ -11103,6 +11104,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/qs": {
|
||||||
"version": "6.13.1",
|
"version": "6.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.13.1.tgz",
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.14.2",
|
"version": "0.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||||
|
|||||||
@ -125,6 +125,7 @@
|
|||||||
"react-leaflet-cluster": "^2.1.0",
|
"react-leaflet-cluster": "^2.1.0",
|
||||||
"react-markdown": "^9.0.1",
|
"react-markdown": "^9.0.1",
|
||||||
"react-photo-album": "^3.0.2",
|
"react-photo-album": "^3.0.2",
|
||||||
|
"react-qr-code": "^2.0.16",
|
||||||
"react-router-dom": "^6.23.0",
|
"react-router-dom": "^6.23.0",
|
||||||
"react-toastify": "^9.1.3",
|
"react-toastify": "^9.1.3",
|
||||||
"remark-breaks": "^4.0.0",
|
"remark-breaks": "^4.0.0",
|
||||||
|
|||||||
@ -47,6 +47,7 @@ export default [
|
|||||||
/node_modules\/tiptap-markdown/,
|
/node_modules\/tiptap-markdown/,
|
||||||
/node_modules\/markdown-it-task-lists/,
|
/node_modules\/markdown-it-task-lists/,
|
||||||
/node_modules\/classnames/,
|
/node_modules\/classnames/,
|
||||||
|
/node_modules\/react-qr-code/,
|
||||||
],
|
],
|
||||||
requireReturnsDefault: 'auto',
|
requireReturnsDefault: 'auto',
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -1,28 +1,65 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { toast } from 'react-toastify'
|
import { toast } from 'react-toastify'
|
||||||
|
|
||||||
|
import { useMyProfile } from '#components/Map/hooks/useMyProfile'
|
||||||
import { MapOverlayPage } from '#components/Templates/MapOverlayPage'
|
import { MapOverlayPage } from '#components/Templates/MapOverlayPage'
|
||||||
|
|
||||||
import { useAuth } from './useAuth'
|
import { useAuth } from './useAuth'
|
||||||
|
|
||||||
|
import type { InviteApi } from '#types/InviteApi'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
inviteApi: InviteApi
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @category Auth
|
* @category Auth
|
||||||
*/
|
*/
|
||||||
export function LoginPage() {
|
export function LoginPage({ inviteApi }: Props) {
|
||||||
const [email, setEmail] = useState<string>('')
|
const [email, setEmail] = useState<string>('')
|
||||||
const [password, setPassword] = useState<string>('')
|
const [password, setPassword] = useState<string>('')
|
||||||
|
|
||||||
const { login, loading } = useAuth()
|
const { login, loading } = useAuth()
|
||||||
|
|
||||||
|
const { myProfile, isMyProfileLoaded } = useMyProfile()
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
const redeemInvite = useCallback(
|
||||||
const onLogin = async () => {
|
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 }), {
|
await toast.promise(login({ email, password }), {
|
||||||
success: {
|
success: {
|
||||||
render({ data }) {
|
render({ data }) {
|
||||||
navigate('/')
|
void handleSuccess()
|
||||||
return `Hi ${data?.first_name ? data.first_name : 'Traveler'}`
|
return `Hi ${data?.first_name ? data.first_name : 'Traveler'}`
|
||||||
},
|
},
|
||||||
// other options
|
// other options
|
||||||
@ -36,7 +73,7 @@ export function LoginPage() {
|
|||||||
},
|
},
|
||||||
pending: 'logging in ...',
|
pending: 'logging in ...',
|
||||||
})
|
})
|
||||||
}
|
}, [email, handleSuccess, login, password])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const keyDownHandler = (event: KeyboardEvent) => {
|
const keyDownHandler = (event: KeyboardEvent) => {
|
||||||
|
|||||||
@ -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 { UserApi } from '#types/UserApi'
|
||||||
import type { UserItem } from '#types/UserItem'
|
import type { UserItem } from '#types/UserItem'
|
||||||
|
|
||||||
interface AuthProviderProps {
|
interface AuthProviderProps {
|
||||||
userApi: UserApi
|
userApi: UserApi
|
||||||
|
inviteApi: InviteApi
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,6 +18,7 @@ interface AuthCredentials {
|
|||||||
|
|
||||||
interface AuthContextProps {
|
interface AuthContextProps {
|
||||||
isAuthenticated: boolean
|
isAuthenticated: boolean
|
||||||
|
isInitialized: boolean
|
||||||
user: UserItem | null
|
user: UserItem | null
|
||||||
login: (credentials: AuthCredentials) => Promise<UserItem | undefined>
|
login: (credentials: AuthCredentials) => Promise<UserItem | undefined>
|
||||||
register: (credentials: AuthCredentials, userName: string) => Promise<UserItem | undefined>
|
register: (credentials: AuthCredentials, userName: string) => Promise<UserItem | undefined>
|
||||||
@ -29,6 +32,7 @@ interface AuthContextProps {
|
|||||||
|
|
||||||
const AuthContext = createContext<AuthContextProps>({
|
const AuthContext = createContext<AuthContextProps>({
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isInitialized: false,
|
||||||
user: null,
|
user: null,
|
||||||
login: () => Promise.reject(Error('Unimplemented')),
|
login: () => Promise.reject(Error('Unimplemented')),
|
||||||
register: () => 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 [user, setUser] = useState<UserItem | null>(null)
|
||||||
const [token, setToken] = useState<string>()
|
const [token, setToken] = useState<string>()
|
||||||
const [loading, setLoading] = useState<boolean>(false)
|
const [loading, setLoading] = useState<boolean>(false)
|
||||||
|
const [isInitialized, setIsInitialized] = useState<boolean>(false)
|
||||||
const isAuthenticated = !!user
|
const isAuthenticated = !!user
|
||||||
|
|
||||||
useEffect(() => {
|
const loadUser: () => Promise<UserItem | undefined> = useCallback(async () => {
|
||||||
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> {
|
|
||||||
try {
|
try {
|
||||||
const token = await userApi.getToken()
|
const token = await userApi.getToken()
|
||||||
setToken(token)
|
setToken(token)
|
||||||
@ -66,20 +63,30 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
|
|||||||
setUser(me)
|
setUser(me)
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return me
|
return me
|
||||||
} else return undefined
|
} else {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
// eslint-disable-next-line no-catch-all/no-catch-all
|
// eslint-disable-next-line no-catch-all/no-catch-all
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return undefined
|
return undefined
|
||||||
|
} finally {
|
||||||
|
setIsInitialized(true)
|
||||||
}
|
}
|
||||||
}
|
}, [userApi])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadUser()
|
||||||
|
}, [loadUser])
|
||||||
|
|
||||||
const login = async (credentials: AuthCredentials): Promise<UserItem | undefined> => {
|
const login = async (credentials: AuthCredentials): Promise<UserItem | undefined> => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const user = await userApi.login(credentials.email, credentials.password)
|
const user = await userApi.login(credentials.email, credentials.password)
|
||||||
setToken(user?.access_token)
|
setToken(user?.access_token)
|
||||||
return await loadUser()
|
const fullUser = await loadUser()
|
||||||
|
|
||||||
|
return fullUser
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
throw error
|
throw error
|
||||||
@ -150,6 +157,7 @@ export const AuthProvider = ({ userApi, children }: AuthProviderProps) => {
|
|||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
|
isInitialized,
|
||||||
user,
|
user,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
|
|||||||
20
lib/src/Components/Map/hooks/useMyProfile.ts
Normal file
20
lib/src/Components/Map/hooks/useMyProfile.ts
Normal 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 }
|
||||||
|
}
|
||||||
75
lib/src/Components/Onboarding/InvitePage.tsx
Normal file
75
lib/src/Components/Onboarding/InvitePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
lib/src/Components/Onboarding/index.ts
Normal file
1
lib/src/Components/Onboarding/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { InvitePage } from './InvitePage'
|
||||||
@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
38
lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx
Normal file
38
lib/src/Components/Profile/Subcomponents/InviteLinkView.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
37
lib/src/Components/Profile/Subcomponents/RelationsView.tsx
Normal file
37
lib/src/Components/Profile/Subcomponents/RelationsView.tsx
Normal 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
@ -17,6 +17,7 @@ const componentMap = {
|
|||||||
startEnd: ProfileStartEndForm,
|
startEnd: ProfileStartEndForm,
|
||||||
crowdfundings: CrowdfundingForm,
|
crowdfundings: CrowdfundingForm,
|
||||||
gallery: GalleryForm,
|
gallery: GalleryForm,
|
||||||
|
inviteLinks: () => null, // Not needed for now
|
||||||
// weitere Komponenten hier
|
// weitere Komponenten hier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,10 @@ import { ContactInfoView } from '#components/Profile/Subcomponents/ContactInfoVi
|
|||||||
import { CrowdfundingView } from '#components/Profile/Subcomponents/CrowdfundingView'
|
import { CrowdfundingView } from '#components/Profile/Subcomponents/CrowdfundingView'
|
||||||
import { GalleryView } from '#components/Profile/Subcomponents/GalleryView'
|
import { GalleryView } from '#components/Profile/Subcomponents/GalleryView'
|
||||||
import { GroupSubHeaderView } from '#components/Profile/Subcomponents/GroupSubHeaderView'
|
import { GroupSubHeaderView } from '#components/Profile/Subcomponents/GroupSubHeaderView'
|
||||||
|
import { InviteLinkView } from '#components/Profile/Subcomponents/InviteLinkView'
|
||||||
import { ProfileStartEndView } from '#components/Profile/Subcomponents/ProfileStartEndView'
|
import { ProfileStartEndView } from '#components/Profile/Subcomponents/ProfileStartEndView'
|
||||||
import { ProfileTextView } from '#components/Profile/Subcomponents/ProfileTextView'
|
import { ProfileTextView } from '#components/Profile/Subcomponents/ProfileTextView'
|
||||||
|
import { RelationsView } from '#components/Profile/Subcomponents/RelationsView'
|
||||||
|
|
||||||
import type { Item } from '#types/Item'
|
import type { Item } from '#types/Item'
|
||||||
import type { Key } from 'react'
|
import type { Key } from 'react'
|
||||||
@ -17,6 +19,8 @@ const componentMap = {
|
|||||||
startEnd: ProfileStartEndView,
|
startEnd: ProfileStartEndView,
|
||||||
gallery: GalleryView,
|
gallery: GalleryView,
|
||||||
crowdfundings: CrowdfundingView,
|
crowdfundings: CrowdfundingView,
|
||||||
|
inviteLinks: InviteLinkView,
|
||||||
|
relations: RelationsView,
|
||||||
// weitere Komponenten hier
|
// weitere Komponenten hier
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export * from './Components/Gaming'
|
|||||||
export * from './Components/Templates'
|
export * from './Components/Templates'
|
||||||
export * from './Components/Input'
|
export * from './Components/Input'
|
||||||
export * from './Components/Item'
|
export * from './Components/Item'
|
||||||
|
export * from './Components/Onboarding'
|
||||||
export * from './Components/Profile'
|
export * from './Components/Profile'
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
4
lib/src/types/InviteApi.d.ts
vendored
Normal file
4
lib/src/types/InviteApi.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export interface InviteApi {
|
||||||
|
validateInvite(inviteId: string): Promise<string | null>
|
||||||
|
redeemInvite(inviteId: string, itemId: string): Promise<string | null>
|
||||||
|
}
|
||||||
5
lib/src/types/Item.d.ts
vendored
5
lib/src/types/Item.d.ts
vendored
@ -18,6 +18,10 @@ interface GalleryItem {
|
|||||||
| string
|
| string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ItemSecret {
|
||||||
|
secret: string
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @category Types
|
* @category Types
|
||||||
*/
|
*/
|
||||||
@ -55,6 +59,7 @@ export interface Item {
|
|||||||
next_appointment?: string
|
next_appointment?: string
|
||||||
gallery?: GalleryItem[]
|
gallery?: GalleryItem[]
|
||||||
openCollectiveSlug?: string
|
openCollectiveSlug?: string
|
||||||
|
secrets?: ItemSecret[]
|
||||||
|
|
||||||
// {
|
// {
|
||||||
// coordinates: [number, number]
|
// coordinates: [number, number]
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user