@brdgapp/ui 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +223 -0
- package/package.json +28 -0
- package/src/api.ts +173 -0
- package/src/contact-avatar.tsx +63 -0
- package/src/domain.ts +14 -0
- package/src/icons.tsx +130 -0
- package/src/index.ts +12 -0
- package/src/network-people-helper.tsx +397 -0
- package/src/network-people-nudge.tsx +98 -0
- package/src/open-helper.ts +22 -0
- package/src/person-menu.tsx +210 -0
- package/src/provider.tsx +53 -0
- package/src/styles.css +848 -0
- package/src/types.ts +30 -0
- package/src/use-network-people-list.ts +122 -0
- package/src/use-network-preview.ts +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# @brdgapp/ui
|
|
2
|
+
|
|
3
|
+
Shows how many people you know at a specific domain, and who they are, from the signed-in user’s Bridge network.
|
|
4
|
+
|
|
5
|
+
Use it on any screen that is about one company or firm. Pass that domain. The package looks up the signed-in user’s contacts whose email domain matches, then renders a floating widget: “You know 6 people at Brdg”, with avatars. Opening it lists those people (name, role, company, email) so the user can make an intro, copy an intro link, or open a Bridge profile. Apps can add their own row actions, such as adding the person as a team member.
|
|
6
|
+
|
|
7
|
+
Nothing appears when the user is signed out, or when they know nobody at that domain.
|
|
8
|
+
|
|
9
|
+
It publishes to the `brdgapp` npm team as `@brdgapp/ui`. Installing it does not need an `.npmrc` or a registry token. Vercel installs it like any other dependency.
|
|
10
|
+
|
|
11
|
+
Source lives in the private GitLab repo `intropath/packages/ui`. The published tarball is what apps install.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
Peer dependencies are `react` and `react-dom` (18 or newer). The app already has those.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pnpm add @brdgapp/ui
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The package publishes TypeScript source, not a prebuilt `dist`. The app bundler must compile it.
|
|
22
|
+
|
|
23
|
+
### Next.js
|
|
24
|
+
|
|
25
|
+
In `next.config.mjs` (or `next.config.js`):
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
const nextConfig = {
|
|
29
|
+
transpilePackages: ["@brdgapp/ui"],
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Import the stylesheet once from the root layout (`app/layout.tsx`). Next.js does not pick up the package’s CSS import on its own.
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
import "@brdgapp/ui/styles.css"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Vite
|
|
40
|
+
|
|
41
|
+
Add the package to `optimizeDeps.exclude` only if the dev server fails to compile its TypeScript. The package entry imports `styles.css`, so a normal Vite app gets the CSS from the JS import. If styles are missing, import `@brdgapp/ui/styles.css` once from the app entry.
|
|
42
|
+
|
|
43
|
+
## Wire it up
|
|
44
|
+
|
|
45
|
+
Render `BridgeUIProvider` once around the tree that contains the helper. It supplies the Bridge API base and the signed-in user’s access token. The helper then calls the contacts API itself.
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
"use client"
|
|
49
|
+
|
|
50
|
+
import { BridgeUIProvider, NetworkPeopleHelper } from "@brdgapp/ui"
|
|
51
|
+
|
|
52
|
+
export function AppProviders({ children }: { children: React.ReactNode }) {
|
|
53
|
+
return (
|
|
54
|
+
<BridgeUIProvider getAccessToken={getAuthToken}>
|
|
55
|
+
{children}
|
|
56
|
+
</BridgeUIProvider>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`apiBaseUrl` defaults to production: `https://api.brdg.app/api/v4`. Pass it only for staging or a local backend, using the same value the app already uses for `NEXT_PUBLIC_API_BASE_URL`. Include the `/api/v4` prefix and leave off the trailing slash.
|
|
62
|
+
|
|
63
|
+
`getAccessToken` returns the bearer token for the signed-in user, or `null` when nobody is signed in. It may be async. A Next.js server action that reads the `authToken` cookie is a valid implementation, because the helper calls it from the browser:
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
"use server"
|
|
67
|
+
|
|
68
|
+
import { cookies } from "next/headers"
|
|
69
|
+
|
|
70
|
+
export async function getAuthToken(): Promise<string | null> {
|
|
71
|
+
const cookieStore = await cookies()
|
|
72
|
+
return cookieStore.get("authToken")?.value ?? null
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Pass that function as `getAccessToken`. Do not put the token, or any other secret, in this package.
|
|
77
|
+
|
|
78
|
+
If `getAccessToken` returns `null`, or the user knows nobody at the domain, the helper stays hidden.
|
|
79
|
+
|
|
80
|
+
## Network people helper
|
|
81
|
+
|
|
82
|
+
```tsx
|
|
83
|
+
import { NetworkPeopleHelper } from "@brdgapp/ui"
|
|
84
|
+
|
|
85
|
+
<NetworkPeopleHelper
|
|
86
|
+
domain={domain}
|
|
87
|
+
placement="bottom-end"
|
|
88
|
+
customActions={[
|
|
89
|
+
{
|
|
90
|
+
id: "add-team-member",
|
|
91
|
+
label: "Add as team member",
|
|
92
|
+
description: "Save them on this firm's team so they show up with the rest of the roster.",
|
|
93
|
+
onSelect: (contact) => addTeamMember(contact),
|
|
94
|
+
},
|
|
95
|
+
]}
|
|
96
|
+
/>
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`domain` is required. The package cleans it (strips protocol, `www.`, path, and query) and sends it as `domain_override`.
|
|
100
|
+
|
|
101
|
+
### What the package does
|
|
102
|
+
|
|
103
|
+
After about 3 seconds on the page it requests:
|
|
104
|
+
|
|
105
|
+
- `GET {apiBaseUrl}/search/owned_full_network_contacts/count?domain_override=`
|
|
106
|
+
- `GET {apiBaseUrl}/search/owned_full_network_contacts?domain_override=&limit=&offset=`
|
|
107
|
+
|
|
108
|
+
Requests send `Authorization: Bearer <token>` from `getAccessToken`.
|
|
109
|
+
|
|
110
|
+
When the count is greater than zero, the widget appears: icon, then the “You know N people at …” note, then a searchable list with infinite scroll. “Do not show again” hides it for the rest of the browser session (`sessionStorage` key `bridge-network-helper-dismissed:<domain>`).
|
|
111
|
+
|
|
112
|
+
### Props
|
|
113
|
+
|
|
114
|
+
| Prop | Required | Default | Purpose |
|
|
115
|
+
| --- | --- | --- | --- |
|
|
116
|
+
| `domain` | yes | — | Company or firm domain to match contacts against. |
|
|
117
|
+
| `organizationLabel` | no | cleaned domain | Text after “at”. Pass a display name such as `"Brdg"` when the screen already has one. The contacts API does not return an organization name. |
|
|
118
|
+
| `enabled` | no | `true` | Set `false` to skip the request and render nothing. |
|
|
119
|
+
| `checkDelayMs` | no | `3000` | Wait after the page visit before the first request. |
|
|
120
|
+
| `placement` | no | `"bottom-end"` | Fixed corner. See below. |
|
|
121
|
+
| `className` | no | — | Extra class on the fixed root. |
|
|
122
|
+
| `style` | no | — | Extra inline style on the fixed root. |
|
|
123
|
+
| `customActions` | no | `[]` | Extra rows in each person’s menu. |
|
|
124
|
+
|
|
125
|
+
`placement` is one of `"bottom-end"`, `"bottom-start"`, `"top-end"`, `"top-start"`. `"bottom-end"` matches the portfolio widget: fixed, bottom-right, with a slightly larger offset from 640px up.
|
|
126
|
+
|
|
127
|
+
Override that position in the app’s CSS. These variables win over the preset:
|
|
128
|
+
|
|
129
|
+
```css
|
|
130
|
+
.my-helper {
|
|
131
|
+
--bridge-helper-bottom: 5rem;
|
|
132
|
+
--bridge-helper-right: 2rem;
|
|
133
|
+
--bridge-helper-z: 50;
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The full set is `--bridge-helper-top`, `--bridge-helper-right`, `--bridge-helper-bottom`, `--bridge-helper-left`, and `--bridge-helper-z`.
|
|
138
|
+
|
|
139
|
+
Colors follow the host app’s `--foreground`, `--background`, `--muted`, `--muted-foreground`, and `--border` when those exist. Otherwise the widget uses its own defaults. The Bridge blue accent is `#0038ff`.
|
|
140
|
+
|
|
141
|
+
### Person menu
|
|
142
|
+
|
|
143
|
+
Built-in actions, shown only when the contact has the field they need:
|
|
144
|
+
|
|
145
|
+
- **Make intro** opens `https://intros.brdg.app/inbox?action=make-intro&n1=<email>` when `email` is set.
|
|
146
|
+
- **Copy intro link** copies `shareLink`.
|
|
147
|
+
- **View Bridge profile** opens `https://os.brdg.app/to/<username>` when `username` is set.
|
|
148
|
+
|
|
149
|
+
Change those URLs on the provider:
|
|
150
|
+
|
|
151
|
+
```tsx
|
|
152
|
+
<BridgeUIProvider
|
|
153
|
+
getAccessToken={getAuthToken}
|
|
154
|
+
introInboxUrl="https://intros.brdg.app/inbox"
|
|
155
|
+
profileUrlForUsername={(username) => `https://os.brdg.app/to/${username}`}
|
|
156
|
+
>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Custom actions are not built in. “Add as team member” and “Add as founder” belong to the app. Pass them only on the screens where they apply. `onSelect` receives the row’s contact:
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
type NetworkContact = {
|
|
163
|
+
id: string
|
|
164
|
+
name: string
|
|
165
|
+
email: string | null
|
|
166
|
+
shareLink: string | null
|
|
167
|
+
profilePicUrl: string | null
|
|
168
|
+
position: string | null
|
|
169
|
+
company: string | null
|
|
170
|
+
username: string | null
|
|
171
|
+
isMember: boolean
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
type NetworkPersonAction = {
|
|
175
|
+
id: string
|
|
176
|
+
label: string
|
|
177
|
+
description?: string
|
|
178
|
+
icon?: React.ReactNode
|
|
179
|
+
onSelect: (contact: NetworkContact) => void
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Custom actions render above the built-in ones. The package does not check edit permission. If the user must not see an action, omit it from `customActions`.
|
|
184
|
+
|
|
185
|
+
## Inline nudge
|
|
186
|
+
|
|
187
|
+
`NetworkPeopleNudge` is the same note, inline in a team or founders section. It uses the same request as the floating helper. Clicking it opens the helper’s people list on this page.
|
|
188
|
+
|
|
189
|
+
```tsx
|
|
190
|
+
import { NetworkPeopleNudge } from "@brdgapp/ui"
|
|
191
|
+
|
|
192
|
+
<NetworkPeopleNudge domain={domain} organizationLabel={displayName} />
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
Pass `onOpen` when the click should do something else. `organizationLabel`, `enabled`, and `className` match the helper.
|
|
196
|
+
|
|
197
|
+
`openNetworkPeopleHelper(domain)` opens the floating helper from any other button. It clears the session dismiss for that domain.
|
|
198
|
+
|
|
199
|
+
## Publish
|
|
200
|
+
|
|
201
|
+
From this repo, after the version in `package.json` is the one you want to release:
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
pnpm login
|
|
205
|
+
pnpm publish
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
`pnpm login` uses the `brdgapp` team account. `publishConfig.access` is `public`, so `pnpm publish` does not need `--access public`.
|
|
209
|
+
|
|
210
|
+
This package has no dependencies of its own, so there is no lockfile to install first. Commit the release before publishing. pnpm refuses to publish a dirty git tree.
|
|
211
|
+
|
|
212
|
+
To release a change, bump `version`, commit, push, then run `pnpm publish` again.
|
|
213
|
+
|
|
214
|
+
## Agent checklist
|
|
215
|
+
|
|
216
|
+
1. Run `pnpm add @brdgapp/ui`.
|
|
217
|
+
2. Next.js: set `transpilePackages: ["@brdgapp/ui"]`.
|
|
218
|
+
3. Import `@brdgapp/ui/styles.css` once in the root layout.
|
|
219
|
+
4. Wrap the app in `BridgeUIProvider` with a `getAccessToken` that returns the signed-in user’s bearer token. The API base defaults to `https://api.brdg.app/api/v4`. Pass `apiBaseUrl` only for staging or local.
|
|
220
|
+
5. Render `NetworkPeopleHelper` with `domain`.
|
|
221
|
+
6. Pass `organizationLabel` only when the screen already has a display name.
|
|
222
|
+
7. Pass `customActions` only for actions this screen owns. `onSelect` gets the `NetworkContact`.
|
|
223
|
+
8. Leave Make intro, Copy intro link, and View Bridge profile to the package.
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@brdgapp/ui",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shows how many people you know at a domain, and who they are, from the signed-in user's Bridge network.",
|
|
5
|
+
"license": "UNLICENSED",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": [
|
|
8
|
+
"**/*.css"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
"src"
|
|
12
|
+
],
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./styles.css": "./src/styles.css"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+ssh://git@gitlab.com/intropath/packages/ui.git"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"react": ">=18",
|
|
26
|
+
"react-dom": ">=18"
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/api.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { cleanDomain } from "./domain"
|
|
2
|
+
import type { BridgeClientConfig, NetworkContact } from "./types"
|
|
3
|
+
|
|
4
|
+
export const PREVIEW_LIMIT = 3
|
|
5
|
+
export const PAGE_SIZE = 20
|
|
6
|
+
const CACHE_MS = 5 * 60 * 1000
|
|
7
|
+
|
|
8
|
+
type PreviewResult = {
|
|
9
|
+
count: number
|
|
10
|
+
contacts: NetworkContact[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type CacheEntry = {
|
|
14
|
+
at: number
|
|
15
|
+
data: PreviewResult
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const previewCache = new Map<string, CacheEntry>()
|
|
19
|
+
const previewInflight = new Map<string, Promise<PreviewResult>>()
|
|
20
|
+
|
|
21
|
+
type ApiContact = {
|
|
22
|
+
id?: unknown
|
|
23
|
+
ref_id?: unknown
|
|
24
|
+
name?: unknown
|
|
25
|
+
given_name?: unknown
|
|
26
|
+
email?: unknown
|
|
27
|
+
share_link?: unknown
|
|
28
|
+
profile_pic_url?: unknown
|
|
29
|
+
position?: unknown
|
|
30
|
+
company?: unknown
|
|
31
|
+
username?: unknown
|
|
32
|
+
is_member?: unknown
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function mapShareLink(raw: unknown): string | null {
|
|
36
|
+
if (typeof raw !== "string") return null
|
|
37
|
+
const trimmed = raw.trim()
|
|
38
|
+
if (!trimmed) return null
|
|
39
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed
|
|
40
|
+
if (trimmed.startsWith("/")) return `https://brdg.app${trimmed}`
|
|
41
|
+
return `https://brdg.app/${trimmed}`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function mapContact(raw: ApiContact): NetworkContact | null {
|
|
45
|
+
const id = String(raw.id ?? raw.ref_id ?? "").trim()
|
|
46
|
+
if (!id) return null
|
|
47
|
+
const name =
|
|
48
|
+
(typeof raw.name === "string" && raw.name.trim()) ||
|
|
49
|
+
(typeof raw.given_name === "string" && raw.given_name.trim()) ||
|
|
50
|
+
"Contact"
|
|
51
|
+
const username = typeof raw.username === "string" && raw.username.trim() ? raw.username.trim() : null
|
|
52
|
+
return {
|
|
53
|
+
id,
|
|
54
|
+
name,
|
|
55
|
+
email: typeof raw.email === "string" && raw.email.trim() ? raw.email.trim() : null,
|
|
56
|
+
shareLink: mapShareLink(raw.share_link),
|
|
57
|
+
profilePicUrl:
|
|
58
|
+
typeof raw.profile_pic_url === "string" && raw.profile_pic_url.trim()
|
|
59
|
+
? raw.profile_pic_url.trim()
|
|
60
|
+
: null,
|
|
61
|
+
position: typeof raw.position === "string" && raw.position.trim() ? raw.position.trim() : null,
|
|
62
|
+
company: typeof raw.company === "string" && raw.company.trim() ? raw.company.trim() : null,
|
|
63
|
+
username,
|
|
64
|
+
isMember: raw.is_member === true && Boolean(username),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function bridgeGet(config: BridgeClientConfig, pathWithQuery: string): Promise<unknown> {
|
|
69
|
+
const token = await config.getAccessToken()
|
|
70
|
+
if (!token) {
|
|
71
|
+
const error = new Error("Not authenticated")
|
|
72
|
+
error.name = "BridgeAuthError"
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const response = await fetch(`${config.apiBaseUrl}${pathWithQuery}`, {
|
|
77
|
+
method: "GET",
|
|
78
|
+
headers: {
|
|
79
|
+
Accept: "application/json",
|
|
80
|
+
Authorization: `Bearer ${token}`,
|
|
81
|
+
},
|
|
82
|
+
cache: "no-store",
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
const error = new Error("Failed to load network contacts.")
|
|
87
|
+
error.name = response.status === 401 ? "BridgeAuthError" : "BridgeRequestError"
|
|
88
|
+
throw error
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return response.json()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function cacheKey(config: BridgeClientConfig, domain: string, token: string): string {
|
|
95
|
+
return `${config.apiBaseUrl}|${token}|${domain}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function fetchNetworkPreview(config: BridgeClientConfig, domain: string): Promise<PreviewResult> {
|
|
99
|
+
const cleaned = cleanDomain(domain)
|
|
100
|
+
if (!cleaned) return Promise.resolve({ count: 0, contacts: [] })
|
|
101
|
+
|
|
102
|
+
const inflightKey = `${config.apiBaseUrl}|${cleaned}`
|
|
103
|
+
const existing = previewInflight.get(inflightKey)
|
|
104
|
+
if (existing) return existing
|
|
105
|
+
|
|
106
|
+
const request = loadCachedPreview(config, cleaned).finally(() => {
|
|
107
|
+
previewInflight.delete(inflightKey)
|
|
108
|
+
})
|
|
109
|
+
previewInflight.set(inflightKey, request)
|
|
110
|
+
return request
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loadCachedPreview(config: BridgeClientConfig, domain: string): Promise<PreviewResult> {
|
|
114
|
+
const token = await config.getAccessToken()
|
|
115
|
+
if (!token) return { count: 0, contacts: [] }
|
|
116
|
+
|
|
117
|
+
const key = cacheKey(config, domain, token)
|
|
118
|
+
const cached = previewCache.get(key)
|
|
119
|
+
if (cached && Date.now() - cached.at < CACHE_MS) return cached.data
|
|
120
|
+
|
|
121
|
+
const data = await loadPreview(config, domain)
|
|
122
|
+
previewCache.set(key, { at: Date.now(), data })
|
|
123
|
+
return data
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function loadPreview(config: BridgeClientConfig, domain: string): Promise<PreviewResult> {
|
|
127
|
+
const baseParams = new URLSearchParams({ domain_override: domain })
|
|
128
|
+
const listParams = new URLSearchParams(baseParams)
|
|
129
|
+
listParams.set("limit", String(PREVIEW_LIMIT))
|
|
130
|
+
listParams.set("offset", "0")
|
|
131
|
+
|
|
132
|
+
const [countBody, listBody] = await Promise.all([
|
|
133
|
+
bridgeGet(config, `/search/owned_full_network_contacts/count?${baseParams.toString()}`),
|
|
134
|
+
bridgeGet(config, `/search/owned_full_network_contacts?${listParams.toString()}`),
|
|
135
|
+
])
|
|
136
|
+
|
|
137
|
+
const countRaw = (countBody as { count?: unknown })?.count
|
|
138
|
+
const count = typeof countRaw === "number" && countRaw >= 0 ? countRaw : 0
|
|
139
|
+
const contactsRaw = (listBody as { contacts?: unknown })?.contacts
|
|
140
|
+
const contacts = Array.isArray(contactsRaw)
|
|
141
|
+
? contactsRaw
|
|
142
|
+
.map((item) => mapContact(item as ApiContact))
|
|
143
|
+
.filter((item): item is NetworkContact => item !== null)
|
|
144
|
+
.slice(0, PREVIEW_LIMIT)
|
|
145
|
+
: []
|
|
146
|
+
|
|
147
|
+
return { count, contacts }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function fetchNetworkPage(
|
|
151
|
+
config: BridgeClientConfig,
|
|
152
|
+
domain: string,
|
|
153
|
+
options: { offset: number; search?: string },
|
|
154
|
+
): Promise<NetworkContact[]> {
|
|
155
|
+
const cleaned = cleanDomain(domain)
|
|
156
|
+
if (!cleaned) return []
|
|
157
|
+
|
|
158
|
+
const offset = Math.max(options.offset, 0)
|
|
159
|
+
const search = options.search?.trim() ?? ""
|
|
160
|
+
const params = new URLSearchParams({
|
|
161
|
+
domain_override: cleaned,
|
|
162
|
+
limit: String(PAGE_SIZE),
|
|
163
|
+
offset: String(offset),
|
|
164
|
+
})
|
|
165
|
+
if (search) params.set("s", search)
|
|
166
|
+
|
|
167
|
+
const body = await bridgeGet(config, `/search/owned_full_network_contacts?${params.toString()}`)
|
|
168
|
+
const contactsRaw = (body as { contacts?: unknown })?.contacts
|
|
169
|
+
if (!Array.isArray(contactsRaw)) return []
|
|
170
|
+
return contactsRaw
|
|
171
|
+
.map((item) => mapContact(item as ApiContact))
|
|
172
|
+
.filter((item): item is NetworkContact => item !== null)
|
|
173
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useState, type CSSProperties } from "react"
|
|
4
|
+
import { CheckIcon } from "./icons"
|
|
5
|
+
import type { NetworkContact } from "./types"
|
|
6
|
+
|
|
7
|
+
const FALLBACK_COUNT = 6
|
|
8
|
+
|
|
9
|
+
function initials(name: string): string {
|
|
10
|
+
const parts = name.trim().split(/\s+/).filter(Boolean)
|
|
11
|
+
if (parts.length === 0) return "?"
|
|
12
|
+
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
|
|
13
|
+
return `${parts[0][0] ?? ""}${parts[parts.length - 1][0] ?? ""}`.toUpperCase()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function colorIndex(seed: string): number {
|
|
17
|
+
if (!seed) return 0
|
|
18
|
+
const code = seed.charCodeAt(0)
|
|
19
|
+
return Number.isFinite(code) ? code % FALLBACK_COUNT : 0
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function ContactAvatar({
|
|
23
|
+
contact,
|
|
24
|
+
size = "sm",
|
|
25
|
+
style,
|
|
26
|
+
}: {
|
|
27
|
+
contact: Pick<NetworkContact, "id" | "name" | "profilePicUrl" | "username" | "isMember">
|
|
28
|
+
size?: "sm" | "md"
|
|
29
|
+
style?: CSSProperties
|
|
30
|
+
}) {
|
|
31
|
+
const [hasError, setHasError] = useState(false)
|
|
32
|
+
const showImage = Boolean(contact.profilePicUrl) && !hasError
|
|
33
|
+
const isMember = contact.isMember || Boolean(contact.username)
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<span
|
|
37
|
+
className={`bridge-network-helper-avatar bridge-network-helper-avatar--${size}`}
|
|
38
|
+
style={style}
|
|
39
|
+
title={contact.name}
|
|
40
|
+
>
|
|
41
|
+
<span className="bridge-network-helper-avatar__face">
|
|
42
|
+
{showImage ? (
|
|
43
|
+
<img
|
|
44
|
+
src={contact.profilePicUrl ?? undefined}
|
|
45
|
+
alt=""
|
|
46
|
+
onError={() => setHasError(true)}
|
|
47
|
+
loading="lazy"
|
|
48
|
+
referrerPolicy="no-referrer"
|
|
49
|
+
/>
|
|
50
|
+
) : (
|
|
51
|
+
<span className={`bridge-network-helper-avatar__fallback is-${colorIndex(contact.name || contact.id)}`}>
|
|
52
|
+
{initials(contact.name)}
|
|
53
|
+
</span>
|
|
54
|
+
)}
|
|
55
|
+
</span>
|
|
56
|
+
{isMember ? (
|
|
57
|
+
<span className="bridge-network-helper-avatar__badge" aria-label="Bridge member" title="Bridge member">
|
|
58
|
+
<CheckIcon />
|
|
59
|
+
</span>
|
|
60
|
+
) : null}
|
|
61
|
+
</span>
|
|
62
|
+
)
|
|
63
|
+
}
|
package/src/domain.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function cleanDomain(domain: string): string {
|
|
2
|
+
if (!domain || typeof domain !== "string") return ""
|
|
3
|
+
let cleaned = domain.trim().replace(/^https?:\/\//i, "")
|
|
4
|
+
cleaned = cleaned.replace(/^www\./i, "")
|
|
5
|
+
cleaned = cleaned.replace(/\/+$/, "")
|
|
6
|
+
cleaned = cleaned.split("/")[0]?.split("?")[0] ?? ""
|
|
7
|
+
return cleaned.toLowerCase()
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function organizationLabelFromDomain(domain: string, organizationLabel?: string | null): string {
|
|
11
|
+
const custom = organizationLabel?.trim()
|
|
12
|
+
if (custom) return custom
|
|
13
|
+
return cleanDomain(domain)
|
|
14
|
+
}
|
package/src/icons.tsx
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { ReactNode, SVGProps } from "react"
|
|
2
|
+
|
|
3
|
+
type IconProps = SVGProps<SVGSVGElement>
|
|
4
|
+
|
|
5
|
+
function Icon({ children, ...props }: IconProps & { children: ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true" {...props}>
|
|
8
|
+
{children}
|
|
9
|
+
</svg>
|
|
10
|
+
)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function SearchIcon(props: IconProps) {
|
|
14
|
+
return (
|
|
15
|
+
<Icon {...props}>
|
|
16
|
+
<circle cx="11" cy="11" r="7" />
|
|
17
|
+
<path d="m20 20-3.5-3.5" strokeLinecap="round" />
|
|
18
|
+
</Icon>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function EyeOffIcon(props: IconProps) {
|
|
23
|
+
return (
|
|
24
|
+
<Icon {...props}>
|
|
25
|
+
<path d="M3 3l18 18" strokeLinecap="round" />
|
|
26
|
+
<path d="M10.6 10.6a2 2 0 0 0 2.8 2.8" strokeLinecap="round" />
|
|
27
|
+
<path d="M9.9 5.1A10.8 10.8 0 0 1 12 5c6 0 10 7 10 7a18 18 0 0 1-3.2 4.1" strokeLinecap="round" />
|
|
28
|
+
<path d="M6.1 6.1A18 18 0 0 0 2 12s4 7 10 7a10.8 10.8 0 0 0 4.1-.8" strokeLinecap="round" />
|
|
29
|
+
</Icon>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ChevronUpIcon(props: IconProps) {
|
|
34
|
+
return (
|
|
35
|
+
<Icon {...props}>
|
|
36
|
+
<path d="m6 14 6-6 6 6" strokeLinecap="round" strokeLinejoin="round" />
|
|
37
|
+
</Icon>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ChevronDownIcon(props: IconProps) {
|
|
42
|
+
return (
|
|
43
|
+
<Icon {...props}>
|
|
44
|
+
<path d="m6 10 6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
|
45
|
+
</Icon>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function CheckIcon(props: IconProps) {
|
|
50
|
+
return (
|
|
51
|
+
<Icon {...props}>
|
|
52
|
+
<path d="m5 12 5 5L20 7" strokeLinecap="round" strokeLinejoin="round" />
|
|
53
|
+
</Icon>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function CopyIcon(props: IconProps) {
|
|
58
|
+
return (
|
|
59
|
+
<Icon {...props}>
|
|
60
|
+
<rect x="9" y="9" width="11" height="11" rx="2" />
|
|
61
|
+
<path d="M5 15V5a2 2 0 0 1 2-2h10" strokeLinecap="round" />
|
|
62
|
+
</Icon>
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function UserPlusIcon(props: IconProps) {
|
|
67
|
+
return (
|
|
68
|
+
<Icon {...props}>
|
|
69
|
+
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" strokeLinecap="round" />
|
|
70
|
+
<circle cx="9" cy="7" r="4" />
|
|
71
|
+
<path d="M19 8v6M22 11h-6" strokeLinecap="round" />
|
|
72
|
+
</Icon>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function LinkIcon(props: IconProps) {
|
|
77
|
+
return (
|
|
78
|
+
<Icon {...props}>
|
|
79
|
+
<path d="M10 13a5 5 0 0 0 7.1.1l2.1-2.1a5 5 0 0 0-7.1-7.1L10.7 5" strokeLinecap="round" />
|
|
80
|
+
<path d="M14 11a5 5 0 0 0-7.1-.1L4.8 13a5 5 0 0 0 7.1 7.1L13.3 19" strokeLinecap="round" />
|
|
81
|
+
</Icon>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function UserIcon(props: IconProps) {
|
|
86
|
+
return (
|
|
87
|
+
<Icon {...props}>
|
|
88
|
+
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" strokeLinecap="round" />
|
|
89
|
+
<circle cx="12" cy="7" r="4" />
|
|
90
|
+
</Icon>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function MoreIcon(props: IconProps) {
|
|
95
|
+
return (
|
|
96
|
+
<Icon {...props}>
|
|
97
|
+
<circle cx="12" cy="5" r="1" fill="currentColor" stroke="none" />
|
|
98
|
+
<circle cx="12" cy="12" r="1" fill="currentColor" stroke="none" />
|
|
99
|
+
<circle cx="12" cy="19" r="1" fill="currentColor" stroke="none" />
|
|
100
|
+
</Icon>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function BridgeHelperIcon({ motion = "nudge" }: { motion?: "nudge" | "wave" | "breath" | "none" }) {
|
|
105
|
+
return (
|
|
106
|
+
<svg
|
|
107
|
+
className={motion === "none" ? "helper-icon" : `helper-icon ${motion}`}
|
|
108
|
+
viewBox="0 0 109 60"
|
|
109
|
+
aria-hidden="true"
|
|
110
|
+
>
|
|
111
|
+
<g>
|
|
112
|
+
<circle className="dot d1" cx="21.5" cy="5.5" r="6" />
|
|
113
|
+
<circle className="dot d2" cx="85.5" cy="5.5" r="6" />
|
|
114
|
+
<circle className="dot d3" cx="21.5" cy="21.5" r="6" />
|
|
115
|
+
<circle className="dot d4" cx="37.5" cy="21.5" r="6" />
|
|
116
|
+
<circle className="dot d5" cx="69.5" cy="21.5" r="6" />
|
|
117
|
+
<circle className="dot d6" cx="85.5" cy="21.5" r="6" />
|
|
118
|
+
<circle className="dot d7" cx="5.5" cy="37.5" r="6" />
|
|
119
|
+
<circle className="dot d8" cx="21.5" cy="37.5" r="6" />
|
|
120
|
+
<circle className="dot d9" cx="37.5" cy="37.5" r="6" />
|
|
121
|
+
<circle className="dot d10" cx="53.5" cy="37.5" r="6" />
|
|
122
|
+
<circle className="dot d11" cx="69.5" cy="37.5" r="6" />
|
|
123
|
+
<circle className="dot d12" cx="85.5" cy="37.5" r="6" />
|
|
124
|
+
<circle className="dot d13" cx="102" cy="37.5" r="6" />
|
|
125
|
+
<circle className="dot d14" cx="21.5" cy="53.5" r="6" />
|
|
126
|
+
<circle className="dot d15" cx="85.5" cy="53.5" r="6" />
|
|
127
|
+
</g>
|
|
128
|
+
</svg>
|
|
129
|
+
)
|
|
130
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import "./styles.css"
|
|
4
|
+
|
|
5
|
+
export { BridgeUIProvider, DEFAULT_API_BASE_URL } from "./provider"
|
|
6
|
+
export type { BridgeUIProviderProps } from "./provider"
|
|
7
|
+
export { NetworkPeopleHelper } from "./network-people-helper"
|
|
8
|
+
export type { NetworkPeopleHelperProps } from "./network-people-helper"
|
|
9
|
+
export { NetworkPeopleNudge } from "./network-people-nudge"
|
|
10
|
+
export type { NetworkPeopleNudgeProps } from "./network-people-nudge"
|
|
11
|
+
export { openNetworkPeopleHelper } from "./open-helper"
|
|
12
|
+
export type { NetworkContact, NetworkPersonAction, HelperPlacement } from "./types"
|