@villagehq/sdk 0.2.0-beta.1 → 0.2.0-beta.2

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/AGENTS.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Put warm introductions in your product: who your users know at a company, and who can introduce them. This page is the whole integration, written for AI coding agents and the developers working with them. Follow it top to bottom.
4
4
 
5
- - Package: `@villagehq/sdk` (SDK version `0.2.0-beta.1`).
5
+ - Package: `@villagehq/sdk` (SDK version `0.2.0-beta.2`).
6
6
  - Published at https://docs.village.ai/build (raw: https://docs.village.ai/build.md). The copy in `node_modules/@villagehq/sdk/AGENTS.md` matches your installed version.
7
7
 
8
8
  ## 1. Quick start
@@ -10,7 +10,7 @@ Put warm introductions in your product: who your users know at a company, and wh
10
10
  Build and verify locally first. No Village account, human approval, card, or real network is needed until Going live.
11
11
 
12
12
  ```bash
13
- npm install @villagehq/sdk@0.2.0-beta.1
13
+ npm install @villagehq/sdk@0.2.0-beta.2
14
14
  ```
15
15
 
16
16
  Use the demo keys below and follow the chosen mode's code example. If billing is not specified yet, start with the payer `user` demo while asking who pays; local verification can continue without a human response. Render `<village-connect>` and `<village-facepile company="stripe.com">`, click Connect, and verify the synthetic result.
@@ -60,36 +60,46 @@ If the app pays, use **payer `app`**. If each user pays through their Village wo
60
60
 
61
61
  ```ts
62
62
  // lib/village.ts
63
- 'use client';
64
-
65
63
  import { createVillage } from '@villagehq/sdk';
66
- import { defineVillageElements } from '@villagehq/sdk/elements';
67
64
 
68
65
  export const village = createVillage({
69
66
  publishableKey: process.env.NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY ?? '',
70
67
  });
71
-
72
- defineVillageElements(village); // does nothing on the server
73
68
  ```
74
69
 
75
70
  ```tsx
76
71
  // components/warm-intros.tsx
77
72
  'use client';
78
73
 
79
- import type {} from '@villagehq/sdk/react'; // JSX types for <village-*>
80
- import '@/lib/village'; // registers the elements
74
+ import type { VillagePathSelectDetail } from '@villagehq/sdk/elements';
75
+ import { useVillageElements } from '@villagehq/sdk/react'; // also the JSX types for <village-*>
76
+ import { useState } from 'react';
77
+
78
+ import { village } from '../lib/village';
79
+
80
+ function describeIntro({ person, introducer }: VillagePathSelectDetail) {
81
+ if (!introducer) return `You know ${person.name} directly.`;
82
+ return `Ask ${introducer.name} to introduce you to ${person.name}.`;
83
+ }
81
84
 
82
85
  export function WarmIntros({ company }: { company: string }) {
86
+ const canRenderElements = useVillageElements(village); // registers them after hydration
87
+ const [selectedPath, setSelectedPath] = useState<VillagePathSelectDetail | null>(null);
88
+ if (!canRenderElements) return null;
89
+
83
90
  return (
84
91
  <section>
85
92
  <village-connect />
86
93
  <village-facepile company={company} />
87
- <village-paths company={company} onvillage-path-select={(event) => askForIntro(event.detail)} />
94
+ <village-paths company={company} onvillage-path-select={(event) => setSelectedPath(event.detail)} />
95
+ {selectedPath && <p>{describeIntro(selectedPath)}</p>}
88
96
  </section>
89
97
  );
90
98
  }
91
99
  ```
92
100
 
101
+ Render `<WarmIntros company="stripe.com" />` from any page. Paths are relative to your project root, or `src/` if you use one. Render the `<village-*>` elements only once `useVillageElements` returns `true`, never from server HTML: React doesn't attach `on*` listeners to custom elements it hydrates, and elements registered before hydration cause a hydration mismatch warning.
102
+
93
103
  Import `village` from `lib/village` wherever you need `village.connect()`, `village.signOut()` or a store. That's the whole integration: no server code. Sessions live in `localStorage` (`village.session.<publishableKey>`), slide for 30 days, and renew on their own. `village.signOut()` disconnects the app; call it when your user signs out.
94
104
 
95
105
  ### Payer `app`, Next.js App Router
@@ -114,11 +124,11 @@ export async function POST(request: Request) {
114
124
  The browser asks your route for it. Create the client per signed-in user:
115
125
 
116
126
  ```tsx
127
+ // components/warm-intros.tsx
117
128
  'use client';
118
129
 
119
130
  import { createVillage } from '@villagehq/sdk';
120
- import { defineVillageElements } from '@villagehq/sdk/elements';
121
- import type {} from '@villagehq/sdk/react';
131
+ import { useVillageElements } from '@villagehq/sdk/react';
122
132
  import { useState } from 'react';
123
133
 
124
134
  async function readVillageToken() {
@@ -129,17 +139,17 @@ async function readVillageToken() {
129
139
  }
130
140
 
131
141
  function createUserVillage() {
132
- const village = createVillage({
142
+ return createVillage({
133
143
  publishableKey: process.env.NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY ?? '',
134
144
  token: readVillageToken,
135
145
  });
136
- defineVillageElements(village);
137
- return village;
138
146
  }
139
147
 
140
148
  // Render with key={user.id}, so another user gets another client.
141
149
  export function WarmIntros({ company }: { company: string }) {
142
- useState(createUserVillage); // one client for this user, created once
150
+ const [village] = useState(createUserVillage); // one client for this user, created once
151
+ const canRenderElements = useVillageElements(village);
152
+ if (!canRenderElements) return null;
143
153
  return (
144
154
  <>
145
155
  <village-connect />
@@ -155,8 +165,8 @@ The SDK calls `token` once, then refreshes it and retries once after a 401 mappe
155
165
 
156
166
  ```html
157
167
  <script type="module">
158
- import { createVillage } from 'https://esm.sh/@villagehq/sdk@0.2.0-beta.1';
159
- import { defineVillageElements } from 'https://esm.sh/@villagehq/sdk@0.2.0-beta.1/elements';
168
+ import { createVillage } from 'https://esm.sh/@villagehq/sdk@0.2.0-beta.2';
169
+ import { defineVillageElements } from 'https://esm.sh/@villagehq/sdk@0.2.0-beta.2/elements';
160
170
 
161
171
  const village = createVillage({ publishableKey: 'pk_test_village_demo_user' }); // a publishable key is public
162
172
  defineVillageElements(village);
@@ -192,17 +202,18 @@ Run the actual page on localhost with the public demo keys before you say you're
192
202
  - [ ] Clicking it connects:
193
203
  - payer `user`: the synthetic user connects without a Village login or account;
194
204
  - payer `app`: with test keys, no modal opens: the test user connects at once. (On your server, `villageApp.test.connectUser({ id, email })` does the same; the page notices on its next `village.user.get()` or reload.)
195
- - [ ] `<village-connect>` goes `syncing`, then `ready` within about 10 s.
205
+ - [ ] `<village-connect>` goes `syncing`, then `ready` within about 10 s. The elements wait without asking for paths meanwhile; if your own REST call gets `409 network_not_synced` during this sync, it is recoverable: call again once `GET /v2/user/status` reads `ready`.
196
206
  - [ ] No Village account, human approval, card, or real network sync was needed for this demo.
197
207
  - [ ] `<village-facepile company="stripe.com">` ends in `found` or `none`. Try 5 domains: the test network has paths for about 70% of them.
198
208
  - [ ] Clicking an item in `<village-paths>` fires `village-path-select` with a `person`.
199
209
  - [ ] Payer `user`: after `village.signOut()`, the elements go back to `signed_out`.
200
- - [ ] The browser console has no CSP errors (see the reference) and no `VillageError`.
210
+ - [ ] The browser console has no hydration warning, no CSP errors (see the reference) and no `VillageError`.
201
211
  - [ ] `VILLAGE_SECRET_KEY` appears only in server code, and `.env.local` is in `.gitignore`.
202
212
 
203
213
  A script check for payer `app`:
204
214
 
205
215
  ```ts
216
+ // scripts/check-village.ts
206
217
  import { createVillageApp } from '@villagehq/sdk/server';
207
218
 
208
219
  const villageApp = createVillageApp({ secretKey: process.env.VILLAGE_SECRET_KEY ?? '' });
@@ -222,7 +233,7 @@ Wrapped SDK calls throw `VillageError` with `code`, `message`, `status`, `reques
222
233
  | <a id="errors-signed_out"></a>`signed_out` | Payer `user`: no session in this browser | Render a button that calls `village.connect()` |
223
234
  | <a id="errors-app_access_revoked"></a>`app_access_revoked` | The user removed your app from Village | Same as `signed_out`: the SDK already cleared the session |
224
235
  | <a id="errors-not_connected"></a>`not_connected` | Payer `app`: the user hasn't linked Village | Call `village.connect()` from a click |
225
- | <a id="errors-not_synced"></a>`not_synced` | The network has not been synced, or sync is unfinished | Connect/sync if needed, then show progress; use `error.syncUrl` when present |
236
+ | <a id="errors-not_synced"></a>`not_synced` | HTTP 409 `network_not_synced`: the first network sync hasn't finished | Recoverable. Show progress; stores and elements load on their own once the network is `ready` |
226
237
  | <a id="errors-out_of_credits"></a>`out_of_credits` | Out of credits. `error.payer` says whose when provided | `user`: link `error.topupUrl` if present. `app`: top up in the dashboard; payer can be null |
227
238
  | <a id="errors-unauthorized"></a>`unauthorized` | Invalid key or token, or the app is inactive (live keys need a card for payer `app`) | `user`: connect again. `app`: check keys, token route and live billing; callback tokens retry once |
228
239
  | <a id="errors-user_required"></a>`user_required` | A payer `user` app called Village without a signed-in user | Call `village.connect()` first; don't mint tokens for payer `user` apps |
@@ -245,7 +256,7 @@ Wrapped SDK calls throw `VillageError` with `code`, `message`, `status`, `reques
245
256
  After the local demo passes, create your own Village app:
246
257
 
247
258
  ```bash
248
- npx @villagehq/sdk@0.2.0-beta.1 init --name "Acme" --payer user # or --payer app
259
+ npx @villagehq/sdk@0.2.0-beta.2 init --name "Acme" --payer user # or --payer app
249
260
  ```
250
261
 
251
262
  `init` defaults to `http://localhost:3000`. If your local app uses another port, pass its actual origin, for example `--origin http://localhost:3010`.
@@ -276,7 +287,7 @@ CLI flags: `--name`, `--origin`, `--payer app|user`, `--yes` (`-y`), `--help` (`
276
287
 
277
288
  ### Elements
278
289
 
279
- Register once with `defineVillageElements(village)` (from `@villagehq/sdk/elements`).
290
+ Register with `defineVillageElements(village)` (from `@villagehq/sdk/elements`). In React, use `useVillageElements(village)` instead and render the elements once it returns `true`, as in the Next.js code above. Registering another client switches every element on the page to it.
280
291
 
281
292
  | Element | Attributes | Shows |
282
293
  | -------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
@@ -329,7 +340,7 @@ Events (bubbling, composed `CustomEvent`s):
329
340
  | `village-connected` | `{ user }`, after `connect()` leaves the user connected (not when they close the popup or modal without connecting) |
330
341
  | `village-error` | `{ error }`, when `connect()` fails, e.g. `popup_blocked` |
331
342
 
332
- **React 19 + Tailwind.** `import type {} from '@villagehq/sdk/react'` adds the JSX types. Events are `on` + the event name:
343
+ **React 19 + Tailwind.** Importing from `@villagehq/sdk/react` (or `import type {} from '@villagehq/sdk/react'`) adds the JSX types. Events are `on` + the event name; they work on elements rendered after `useVillageElements` returns `true`:
333
344
 
334
345
  ```tsx
335
346
  <village-facepile
@@ -378,7 +389,7 @@ What the stores do for you:
378
389
  - **Shared**: the same company returns the same store, so 50 badges make one fetch.
379
390
  - **Batched**: every `check` asked for in the same tick goes out as one request (100 companies each).
380
391
  - **Credit-safe**: a store fetches on its first subscriber and keeps its result after the last one leaves. It never retries on its own; `refresh()` does.
381
- - **Live**: a `not_synced` store fetches again when the network turns ready; every store resets on sign-out.
392
+ - **Live**: while the first sync runs, a store waits as `not_synced` without asking Village for paths, then fetches once the network turns ready; every store resets on sign-out.
382
393
 
383
394
  React:
384
395
 
@@ -393,7 +404,7 @@ function IntroBadge({ company }: { company: string }) {
393
404
  }
394
405
  ```
395
406
 
396
- Also `useVillageUser(village)` → `{ user, status, isLoading, error, refresh }`, `useVillageConnect(village)` → `{ connect, isConnecting }`, and `<VillageGate village fallback>`.
407
+ Also `useVillageElements(village)` → `true` once the elements can render (see section 4), `useVillageUser(village)` → `{ user, status, isLoading, error, refresh }`, `useVillageConnect(village)` → `{ connect, isConnecting }`, and `<VillageGate village fallback>`.
397
408
 
398
409
  Vue:
399
410
 
package/README.md CHANGED
@@ -5,17 +5,54 @@ Start at https://docs.village.ai/build.
5
5
  Put Village warm introductions in your product: who your users know at a company, and who can introduce them. The complete guide also ships as [`AGENTS.md`](./AGENTS.md), matching your installed SDK version.
6
6
 
7
7
  ```bash
8
- npm install @villagehq/sdk@0.2.0-beta.1
8
+ npm install @villagehq/sdk@0.2.0-beta.2
9
9
  ```
10
10
 
11
- Start locally with the public synthetic demo keys from the guide. No Village account, human approval, card, or real network is needed. Follow the chosen payer mode; if it is unclear, ask who pays asynchronously and verify the payer `user` demo while waiting. Run `npx @villagehq/sdk@0.2.0-beta.1 init` when you reach Going live.
11
+ ## Demo in two minutes (Next.js App Router)
12
12
 
13
- | Import | What |
14
- | ------------------------- | ----------------------------------------------------------------------------------------------------------------- |
15
- | `@villagehq/sdk` | `createVillage`: user status, `connect()`, stores (`village.paths.*`), search, paths, enrichment, the typed `api` |
16
- | `@villagehq/sdk/elements` | `<village-connect>`, `<village-facepile>`, `<village-paths>` web components |
17
- | `@villagehq/sdk/react` | `useVillage(store)`, `useVillageUser`, `useVillageConnect`, `<VillageGate>`, JSX types for the elements |
18
- | `@villagehq/sdk/server` | `createVillageApp({ secretKey })`: tokens, acting as a user, test users |
13
+ Add the public demo key to `.env.local`. It works on `localhost` against a synthetic network, with no account:
14
+
15
+ ```dotenv
16
+ NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY=pk_test_village_demo_user
17
+ ```
18
+
19
+ ```tsx
20
+ // components/warm-intros.tsx
21
+ 'use client';
22
+
23
+ import { createVillage } from '@villagehq/sdk';
24
+ import { useVillageElements } from '@villagehq/sdk/react';
25
+
26
+ const village = createVillage({
27
+ publishableKey: process.env.NEXT_PUBLIC_VILLAGE_PUBLISHABLE_KEY ?? '',
28
+ });
29
+
30
+ export function WarmIntros({ company = 'stripe.com' }: { company?: string }) {
31
+ const canRenderElements = useVillageElements(village); // registers the elements after hydration
32
+ if (!canRenderElements) return null;
33
+
34
+ return (
35
+ <section>
36
+ <village-connect />
37
+ <village-facepile company={company} />
38
+ <village-paths company={company} />
39
+ </section>
40
+ );
41
+ }
42
+ ```
43
+
44
+ Render `<WarmIntros />` in `app/page.tsx`, restart `npm run dev` and open the page. You should see a **Connect Village** button. Click it: no login opens, it shows "Syncing your network…" for about 5 seconds, then "Village connected", a facepile with a strength label, and a list of people who can introduce you. The people are synthetic. About 30% of domains have no paths and show "No warm intros yet". The console stays free of errors.
45
+
46
+ There is no `Village.init`, `Village.findPaths` or default export, and `@village-ai/sdk` is another package: use `createVillage`, `village.paths.*` and the `<village-*>` elements.
47
+
48
+ Start locally with the public synthetic demo keys from the guide. No Village account, human approval, card, or real network is needed. Follow the chosen payer mode; if it is unclear, ask who pays asynchronously and verify the payer `user` demo while waiting. Run `npx @villagehq/sdk@0.2.0-beta.2 init` when you reach Going live.
49
+
50
+ | Import | What |
51
+ | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
52
+ | `@villagehq/sdk` | `createVillage`: user status, `connect()`, stores (`village.paths.*`), search, paths, enrichment, the typed `api` |
53
+ | `@villagehq/sdk/elements` | `<village-connect>`, `<village-facepile>`, `<village-paths>` web components |
54
+ | `@villagehq/sdk/react` | `useVillageElements`, `useVillage(store)`, `useVillageUser`, `useVillageConnect`, `<VillageGate>`, JSX types for the elements |
55
+ | `@villagehq/sdk/server` | `createVillageApp({ secretKey })`: tokens, acting as a user, test users |
19
56
 
20
57
  ## Developing
21
58
 
@@ -55,29 +55,14 @@ type PathsStoresOptions<PersonPaths, CompanyPathsPage> = {
55
55
  company: (page: CompanyPathsPage) => boolean;
56
56
  };
57
57
  watchUser: () => () => void;
58
+ isFirstSyncRunning: () => Promise<boolean>;
58
59
  };
59
60
  export type CompanyPathsStoreOptions = {
60
61
  degrees?: Array<1 | 2 | 3>;
61
62
  limit?: number;
62
63
  };
63
- export declare function createPathsStores<PersonPaths, CompanyPathsPage>({ payer, checkCompany, personPaths, companyPaths, hasPaths, watchUser, }: PathsStoresOptions<PersonPaths, CompanyPathsPage>): {
64
- check: (company: string) => import("./store.js").VillageStore<{
65
- kind: "signed_out";
66
- } | {
67
- kind: "not_connected";
68
- } | {
69
- kind: "not_synced";
70
- } | {
71
- kind: "out_of_credits";
72
- payer: Payer;
73
- topupUrl: string | null;
74
- } | LoadingState | NoneState | ErrorState | {
75
- kind: "found";
76
- people: FacepilePerson[];
77
- more: number;
78
- strength: number | null;
79
- strength_label: string | null;
80
- }>;
64
+ export declare function createPathsStores<PersonPaths, CompanyPathsPage>({ payer, checkCompany, personPaths, companyPaths, hasPaths, watchUser, isFirstSyncRunning, }: PathsStoresOptions<PersonPaths, CompanyPathsPage>): {
65
+ check: (company: string) => import("./store.js").VillageStore<PathsCheckState>;
81
66
  person: (personInput: string) => import("./store.js").VillageStore<PathsState<PersonPaths>>;
82
67
  company: (companyInput: string, { degrees, limit }?: CompanyPathsStoreOptions) => import("./store.js").VillageStore<PathsState<CompanyPathsPage>>;
83
68
  reloadAll: () => void;
@@ -4,6 +4,7 @@ import { createStore } from './store.js';
4
4
  const BULK_CHECK_LIMIT = 100;
5
5
  const LOADING = { kind: 'loading' };
6
6
  const NONE = { kind: 'none' };
7
+ const NOT_SYNCED = { kind: 'not_synced' };
7
8
  const BLOCKED_STATE_BY_CODE = {
8
9
  signed_out: { kind: 'signed_out' },
9
10
  app_access_revoked: { kind: 'signed_out' },
@@ -61,7 +62,7 @@ function toCheckState(check) {
61
62
  function waitsForNetwork(state) {
62
63
  return state.kind === 'not_synced';
63
64
  }
64
- export function createPathsStores({ payer, checkCompany, personPaths, companyPaths, hasPaths, watchUser, }) {
65
+ export function createPathsStores({ payer, checkCompany, personPaths, companyPaths, hasPaths, watchUser, isFirstSyncRunning, }) {
65
66
  const checks = new Map();
66
67
  const people = new Map();
67
68
  const companies = new Map();
@@ -84,11 +85,17 @@ export function createPathsStores({ payer, checkCompany, personPaths, companyPat
84
85
  }
85
86
  function check(company) {
86
87
  const identifier = toCompanyIdentifier(company);
87
- return cached(checks, JSON.stringify(identifier), LOADING, () => checkCompany(identifier).then(toCheckState, toFailureState));
88
+ return cached(checks, JSON.stringify(identifier), LOADING, async () => {
89
+ if (await isFirstSyncRunning())
90
+ return NOT_SYNCED;
91
+ return checkCompany(identifier).then(toCheckState, toFailureState);
92
+ });
88
93
  }
89
94
  function person(personInput) {
90
95
  const identifier = toPersonIdentifier(personInput);
91
96
  return cached(people, JSON.stringify(identifier), LOADING, async () => {
97
+ if (await isFirstSyncRunning())
98
+ return NOT_SYNCED;
92
99
  try {
93
100
  const paths = await personPaths(personInput);
94
101
  if (paths === null || !hasPaths.person(paths))
@@ -104,6 +111,8 @@ export function createPathsStores({ payer, checkCompany, personPaths, companyPat
104
111
  const identifier = toCompanyIdentifier(companyInput);
105
112
  const key = JSON.stringify([identifier, degrees ?? null, limit ?? null]);
106
113
  return cached(companies, key, LOADING, async () => {
114
+ if (await isFirstSyncRunning())
115
+ return NOT_SYNCED;
107
116
  try {
108
117
  const page = await companyPaths(companyInput, { ...(degrees ? { degrees } : {}), ...(limit ? { limit } : {}) });
109
118
  if (page === null || !hasPaths.company(page))
@@ -1,5 +1,4 @@
1
1
  import type { components, paths } from '../generated/api.js';
2
- import type { Payer } from './api-types.js';
3
2
  import { isVillageError, VillageError } from './errors.js';
4
3
  import { type Page } from './pages.js';
5
4
  import { type SessionStorage } from './session.js';
@@ -78,30 +77,7 @@ export declare function createVillage(options: VillageOptions): {
78
77
  connect: () => Promise<VillageUser>;
79
78
  signOut: () => Promise<void>;
80
79
  paths: {
81
- check: (company: string) => import("./store.js").VillageStore<{
82
- kind: "signed_out";
83
- } | {
84
- kind: "not_connected";
85
- } | {
86
- kind: "not_synced";
87
- } | {
88
- kind: "out_of_credits";
89
- payer: Payer;
90
- topupUrl: string | null;
91
- } | {
92
- kind: "loading";
93
- } | {
94
- kind: "none";
95
- } | {
96
- kind: "error";
97
- error: VillageError;
98
- } | {
99
- kind: "found";
100
- people: import("./api-types.js").FacepilePerson[];
101
- more: number;
102
- strength: number | null;
103
- strength_label: string | null;
104
- }>;
80
+ check: (company: string) => import("./store.js").VillageStore<import("./paths-stores.js").PathsCheckState>;
105
81
  person: (personInput: string) => import("./store.js").VillageStore<import("./paths-stores.js").PathsState<{
106
82
  person: components["schemas"]["PersonInfo"];
107
83
  summary: components["schemas"]["PathSummary"];
@@ -13,6 +13,7 @@ export { linkedinSlug, linkedinUrl } from './identifiers.js';
13
13
  export { openSyncModal } from './sync-modal.js';
14
14
  export { isNetworkReady } from './user.js';
15
15
  const DEFAULT_APP_URL = 'https://village.ai';
16
+ const DEMO_KEYS_URL = 'https://docs.village.ai/build#2-public-demo-keys';
16
17
  const SEARCH_TIMEOUT_MS = 120_000;
17
18
  const PATHS_TIMEOUT_MS = 60_000;
18
19
  const TEST_MODE = 'test';
@@ -43,7 +44,7 @@ function requirePublishableKey(publishableKey) {
43
44
  return publishableKey;
44
45
  throw new VillageError({
45
46
  code: 'bad_request',
46
- message: 'Pass createVillage({ publishableKey }) when users pay, or { token } when your app pays. Run `npx @villagehq/sdk init` to get keys.',
47
+ message: `Pass createVillage({ publishableKey }) when users pay, or { token } when your app pays. To build locally, use the public demo key pk_test_village_demo_user (no account needed): ${DEMO_KEYS_URL}`,
47
48
  });
48
49
  }
49
50
  function signedOutError() {
@@ -149,6 +150,25 @@ export function createVillage(options) {
149
150
  }
150
151
  }
151
152
  const { store: user, replace: replaceUser } = createUserStore(readUser, handleUserChange);
153
+ let sharedUserRead = null;
154
+ // Stores that load together share one status read.
155
+ function readUserOnce() {
156
+ sharedUserRead ??= user
157
+ .get()
158
+ .catch(() => null)
159
+ .finally(() => {
160
+ sharedUserRead = null;
161
+ });
162
+ return sharedUserRead;
163
+ }
164
+ async function isFirstSyncRunning() {
165
+ if (isNetworkReady(user.snapshot().user))
166
+ return false;
167
+ await readUserOnce();
168
+ // The snapshot, not this read: a newer read may already have found the network ready.
169
+ const latest = user.snapshot().user;
170
+ return latest?.status === 'syncing' && latest.network_updated_at === null;
171
+ }
152
172
  function endSession() {
153
173
  replaceUser(SIGNED_OUT_USER);
154
174
  pathStores.reloadAll();
@@ -268,6 +288,7 @@ export function createVillage(options) {
268
288
  },
269
289
  // A fresh listener each time: the user store keeps listeners in a Set.
270
290
  watchUser: () => user.subscribe(() => undefined),
291
+ isFirstSyncRunning,
271
292
  });
272
293
  async function joinGroup(inviteLink, options) {
273
294
  try {
@@ -132,9 +132,11 @@ function createElementClasses(requireVillage) {
132
132
  connectedCallback() {
133
133
  this.unbind();
134
134
  this.stopWatching = this.watch(requireVillage());
135
+ connectedElements.add(this);
135
136
  }
136
137
  disconnectedCallback() {
137
138
  this.unbind();
139
+ connectedElements.delete(this);
138
140
  }
139
141
  attributeChangedCallback() {
140
142
  if (this.stopWatching)
@@ -286,13 +288,16 @@ function createElementClasses(requireVillage) {
286
288
  };
287
289
  }
288
290
  let activeVillage = null;
291
+ const connectedElements = new Set();
289
292
  function requireVillage() {
290
293
  if (activeVillage)
291
294
  return activeVillage;
292
295
  throw new Error('Call defineVillageElements(village) before using Village elements.');
293
296
  }
294
297
  // Safe to import on a server: nothing touches the DOM until this runs in a browser.
298
+ // Call it again with another client (another signed-in user) and every element on the page switches to it.
295
299
  export function defineVillageElements(village) {
300
+ const isNewClient = activeVillage !== null && activeVillage !== village;
296
301
  activeVillage = village;
297
302
  if (typeof customElements === 'undefined')
298
303
  return;
@@ -300,4 +305,7 @@ export function defineVillageElements(village) {
300
305
  if (!customElements.get(tag))
301
306
  customElements.define(tag, elementClass);
302
307
  }
308
+ if (isNewClient)
309
+ for (const element of connectedElements)
310
+ element.connectedCallback();
303
311
  }
@@ -2,7 +2,14 @@ import { type DetailedHTMLProps, type HTMLAttributes, type ReactNode } from 'rea
2
2
  import type { VillageStore } from '../core/store.js';
3
3
  import { type VillageUser } from '../core/user.js';
4
4
  import type { Village } from '../core/village.js';
5
- import type { VillageConnectedDetail, VillageErrorDetail, VillagePathSelectDetail } from '../elements/elements.js';
5
+ import { type VillageConnectedDetail, type VillageErrorDetail, type VillagePathSelectDetail } from '../elements/elements.js';
6
+ /**
7
+ * Registers the Village elements for this client after hydration and returns true once they can
8
+ * render. Render `<village-*>` only when it does: React doesn't attach `on*` listeners to custom
9
+ * elements it hydrates from server HTML, an element defined before hydration causes a mismatch
10
+ * warning, and an element mounted before its client is registered would show the previous client's data.
11
+ */
12
+ export declare function useVillageElements(village: Village): boolean;
6
13
  export declare function useVillage<State>(store: VillageStore<State>): State;
7
14
  export declare function useVillageUser(village: Village): {
8
15
  user: VillageUser | null;
@@ -1,9 +1,24 @@
1
1
  'use client';
2
- import { useCallback, useState, useSyncExternalStore, } from 'react';
2
+ import { useCallback, useEffect, useState, useSyncExternalStore, } from 'react';
3
3
  import { isNetworkReady } from '../core/user.js';
4
+ import { defineVillageElements, } from '../elements/elements.js';
4
5
  function ignoreFailure() {
5
6
  return undefined;
6
7
  }
8
+ /**
9
+ * Registers the Village elements for this client after hydration and returns true once they can
10
+ * render. Render `<village-*>` only when it does: React doesn't attach `on*` listeners to custom
11
+ * elements it hydrates from server HTML, an element defined before hydration causes a mismatch
12
+ * warning, and an element mounted before its client is registered would show the previous client's data.
13
+ */
14
+ export function useVillageElements(village) {
15
+ const [registeredVillage, setRegisteredVillage] = useState(null);
16
+ useEffect(() => {
17
+ defineVillageElements(village);
18
+ setRegisteredVillage(village);
19
+ }, [village]);
20
+ return registeredVillage === village;
21
+ }
7
22
  export function useVillage(store) {
8
23
  const subscribe = useCallback((onChange) => store.subscribe(onChange), [store]);
9
24
  return useSyncExternalStore(subscribe, store.get, store.get);
@@ -1,5 +1,4 @@
1
1
  import type { paths } from '../generated/api.js';
2
- import { VillageError } from '../core/errors.js';
3
2
  import { type CallOptions, type TransportOptions } from '../core/transport.js';
4
3
  type AppUpdate = NonNullable<paths['/v2/app']['patch']['requestBody']>['content']['application/json'];
5
4
  export type TokenGrant = 'full' | 'public';
@@ -23,30 +22,7 @@ export declare function createVillageApp({ secretKey, appUrl, ...transportOption
23
22
  connect: () => Promise<import("../core/user.js").VillageUser>;
24
23
  signOut: () => Promise<void>;
25
24
  paths: {
26
- check: (company: string) => import("../core/store.js").VillageStore<{
27
- kind: "signed_out";
28
- } | {
29
- kind: "not_connected";
30
- } | {
31
- kind: "not_synced";
32
- } | {
33
- kind: "out_of_credits";
34
- payer: import("../core/api-types.js").Payer;
35
- topupUrl: string | null;
36
- } | {
37
- kind: "loading";
38
- } | {
39
- kind: "none";
40
- } | {
41
- kind: "error";
42
- error: VillageError;
43
- } | {
44
- kind: "found";
45
- people: import("../core/api-types.js").FacepilePerson[];
46
- more: number;
47
- strength: number | null;
48
- strength_label: string | null;
49
- }>;
25
+ check: (company: string) => import("../core/store.js").VillageStore<import("../core/paths-stores.js").PathsCheckState>;
50
26
  person: (personInput: string) => import("../core/store.js").VillageStore<import("../core/paths-stores.js").PathsState<{
51
27
  person: import("../generated/api.js").components["schemas"]["PersonInfo"];
52
28
  summary: import("../generated/api.js").components["schemas"]["PathSummary"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@villagehq/sdk",
3
- "version": "0.2.0-beta.1",
3
+ "version": "0.2.0-beta.2",
4
4
  "description": "Put Village warm intros in your app: intro paths, people and company search, drop-in web components, and a setup CLI.",
5
5
  "keywords": [
6
6
  "village",
@@ -65,6 +65,7 @@
65
65
  "devDependencies": {
66
66
  "@testing-library/react": "^16.3.0",
67
67
  "@types/react": "^19.1.0",
68
+ "@types/react-dom": "^19.3.0",
68
69
  "jsdom": "^26.1.0",
69
70
  "openapi-typescript": "^7.10.0",
70
71
  "react": "^19.1.0",