@realadvisor-sites/sdk 0.0.0-stage → 0.4.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/LICENSE +21 -0
- package/README.md +330 -2
- package/package.json +32 -4
- package/src/index.ts +399 -0
- package/src/types.ts +342 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 RealAdvisor SA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,331 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @realadvisor/sites-sdk
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The data client of a RealAdvisor site. It reads what an agency publishes from
|
|
4
|
+
its CRM (organisation, teams, agents, listings, new-build developments) and
|
|
5
|
+
sends enquiries back to it.
|
|
6
|
+
|
|
7
|
+
It is a thin client of the REST API's `/v3/public` resource. The API owns
|
|
8
|
+
every data rule (what is public, how prices and addresses are shown, caching).
|
|
9
|
+
The SDK adds four things: transport, types, one error code per failure, and a
|
|
10
|
+
ready-made enquiry endpoint.
|
|
11
|
+
|
|
12
|
+
- No dependencies, web standards only (`fetch`, `Request`, `Response`). It
|
|
13
|
+
runs unchanged in Astro, React Router, a Cloudflare Worker and Node 22.
|
|
14
|
+
- Server-side only. The key must never reach the browser.
|
|
15
|
+
- On npm as `@realadvisor-sites/sdk`, installed as `@realadvisor/sites-sdk`
|
|
16
|
+
(`"npm:@realadvisor-sites/sdk@^0.4.0"`), or by path (`file:../sdk`).
|
|
17
|
+
- The kit core (`kit-core`) and both kits (`kit-astro`, `kit-react`) take it
|
|
18
|
+
as a peer; a site depends on it next to its kit, same version.
|
|
19
|
+
|
|
20
|
+
## Quick start
|
|
21
|
+
|
|
22
|
+
Set two environment variables. The CRM sets both when it publishes a site.
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
SITES_API_URL=https://rest.realadvisor.com # the REST API origin
|
|
26
|
+
SITES_API_KEY=... # the site's own key (a secret)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Create one client and read:
|
|
30
|
+
|
|
31
|
+
```ts
|
|
32
|
+
import { createClient } from '@realadvisor/sites-sdk';
|
|
33
|
+
|
|
34
|
+
const ra = createClient({
|
|
35
|
+
// The site's portals by name, `main` required. Without portals: the site's own.
|
|
36
|
+
portals: { main: '939666fc-c8d9-49e9-8e4b-19a6e9177e21' },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const organisation = await ra.organisation();
|
|
40
|
+
const { data, pagination } = await ra.listings({ offer_type: 'buy', limit: 12 });
|
|
41
|
+
|
|
42
|
+
console.log(organisation.name, pagination.total);
|
|
43
|
+
for (const listing of data) console.log(listing.title, listing.price);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The client reads the two variables on each call, not when it is created. A
|
|
47
|
+
build with no data access only fails where it reads data.
|
|
48
|
+
|
|
49
|
+
## Reading data
|
|
50
|
+
|
|
51
|
+
| Method | Returns | Notes |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `organisation()` | `Organisation` | The agency the site is for. |
|
|
54
|
+
| `teams()` | `Team[]` | Every team (office). The site's own team is first and has `primary: true`. |
|
|
55
|
+
| `agents({ team_id? })` | `Agent[]` | The whole organisation without `team_id`. |
|
|
56
|
+
| `listings(query?)` | `Page<Listing>` | One page. See the filters below. |
|
|
57
|
+
| `allListings(query?)` | `Listing[]` | Every page of `listings(query)`. |
|
|
58
|
+
| `listing(slugOrId, { locale? })` | `Listing \| null` | Live on any of the site's portals. `null` when not found. |
|
|
59
|
+
| `developments({ team_id?, locale? })` | `Development[]` | New-build developments, without their units. |
|
|
60
|
+
| `development(slugOrId, { locale? })` | `DevelopmentWithUnits \| null` | One development with its units. `null` when not found. |
|
|
61
|
+
| `turnstileSiteKey()` | `string \| null` | The enquiry form's Turnstile key, kept 5 minutes. `null`: no widget. |
|
|
62
|
+
|
|
63
|
+
Not found is never an error: `listing()` and `development()` answer `null`.
|
|
64
|
+
|
|
65
|
+
### Listing filters
|
|
66
|
+
|
|
67
|
+
Every filter is optional.
|
|
68
|
+
|
|
69
|
+
| Filter | Type | Meaning |
|
|
70
|
+
| --- | --- | --- |
|
|
71
|
+
| `portal` | `string` | A name from the client's `portals`. Default `main`. |
|
|
72
|
+
| `team_id` | `string` | Listings of one team's brokers. |
|
|
73
|
+
| `offer_type` | `'buy' \| 'rent'` | |
|
|
74
|
+
| `property_type` | `string` | Exact type name or a prefix: `apartment` matches `apartment_penthouse`. |
|
|
75
|
+
| `status` | `'available' \| 'sold'` | |
|
|
76
|
+
| `city` | `string` | Case- and accent-insensitive: `geneve` finds Genève. |
|
|
77
|
+
| `min_price`, `max_price` | `number` | |
|
|
78
|
+
| `min_rooms` | `number` | |
|
|
79
|
+
| `locale` | `string` | The page language. See Languages. |
|
|
80
|
+
| `limit` | `number` | 1 to 200. Default 24 (the API's own default is 50). |
|
|
81
|
+
| `offset` | `number` | Up to 10 000. |
|
|
82
|
+
|
|
83
|
+
### Languages
|
|
84
|
+
|
|
85
|
+
`listings`, `allListings`, `listing`, `developments` and `development` take a
|
|
86
|
+
`locale` (`de`, `fr-CH`, ...). With it, a listing's `title`, `description` and
|
|
87
|
+
`description_text` (a live unit's `description`) come in that language when
|
|
88
|
+
the agency wrote them in the CRM (the listing's custom texts, as its brochure
|
|
89
|
+
shows them), else the original. `text_locale` is set when every text came in
|
|
90
|
+
that language: mark it as the element's `lang`. A development's own texts
|
|
91
|
+
have one language in the CRM. Without `locale`, the original texts. Both kits
|
|
92
|
+
pass the page locale on a site with more than one locale.
|
|
93
|
+
|
|
94
|
+
### Pages
|
|
95
|
+
|
|
96
|
+
`listings()` answers one page, exactly as the API sends it:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
type Page<T> = {
|
|
100
|
+
data: T[];
|
|
101
|
+
pagination: {
|
|
102
|
+
limit: number;
|
|
103
|
+
offset: number;
|
|
104
|
+
total: number;
|
|
105
|
+
total_is_capped: boolean; // true when `total` stopped counting at its cap
|
|
106
|
+
has_more: boolean;
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`teams()`, `agents()`, `developments()` and `allListings()` read every page for
|
|
112
|
+
you and answer a plain array. The API accepts no offset past 10 000, so the
|
|
113
|
+
array ends after 10 200 rows.
|
|
114
|
+
|
|
115
|
+
### Portals
|
|
116
|
+
|
|
117
|
+
A portal is a publication channel in the CRM. A listing shows on a site when
|
|
118
|
+
it is live on one of the site's portals.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
const ra = createClient({
|
|
122
|
+
portals: { main: '<portal id>', luxury: '<another portal id>' },
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await ra.listings(); // listings of `main`
|
|
126
|
+
await ra.listings({ portal: 'luxury' }); // listings of `luxury`
|
|
127
|
+
await ra.listing(slug); // found on any of the two
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Enquiries always go to `main`. A client without `portals` names none: rest-api
|
|
131
|
+
then uses the site's own portal, for reads and enquiries.
|
|
132
|
+
|
|
133
|
+
## What the data looks like
|
|
134
|
+
|
|
135
|
+
The types are the `/v3/public` schemas of the REST API, field for field, in
|
|
136
|
+
the API's snake_case (`offer_type`, `first_name`, `living_surface`). A type
|
|
137
|
+
test in `rest-api` fails when the two differ, so the types cannot drift.
|
|
138
|
+
|
|
139
|
+
Four rules hold for every answer:
|
|
140
|
+
|
|
141
|
+
1. **A field with no value is absent.** Never `null`, never `''`. Test with
|
|
142
|
+
`if (listing.price)`, not against `null`.
|
|
143
|
+
2. **Arrays are always arrays.** `listing.photos` and `listing.features` exist
|
|
144
|
+
even when empty.
|
|
145
|
+
3. **Descriptions are sanitized HTML.** Only `p`, `br`, `ul`, `ol`, `li`,
|
|
146
|
+
`strong`, `em`, `h3`, `h4`, no attributes. `description_text` is the same
|
|
147
|
+
text without tags, for meta tags and cards.
|
|
148
|
+
4. **Coordinates are never exact.** `lat` and `lng` are rounded as the CRM's
|
|
149
|
+
address display option asks (about 10 m, 100 m or 1 km).
|
|
150
|
+
|
|
151
|
+
A hidden price is absent, and `price_on_request` is then `true`; so are the
|
|
152
|
+
prices that go with it (`rent_extra`, the parking prices).
|
|
153
|
+
|
|
154
|
+
### Listing and unit facts
|
|
155
|
+
|
|
156
|
+
Both carry these, each absent when the CRM has none. Money is in the record's
|
|
157
|
+
`currency`, surfaces in m².
|
|
158
|
+
|
|
159
|
+
| Field | Meaning |
|
|
160
|
+
| --- | --- |
|
|
161
|
+
| `balcony_surface`, `terrace_surface`, `garden_surface` | Outdoor surfaces. |
|
|
162
|
+
| `floor` | 0 = ground floor. `building_floors`: floors of the building. |
|
|
163
|
+
| `orientation` | `['south', 'west']`: the directions it faces. |
|
|
164
|
+
| `construction_year`, `renovation_year` | |
|
|
165
|
+
| `energy_class` | `A` to `G`. `energy_certificate_version`: the certificate it is from, as typed in the CRM. |
|
|
166
|
+
| `minergie` | `minergie`, `minergie_p`, `minergie_eco` or `minergie_p_eco`. |
|
|
167
|
+
| `heating` | CRM key: `heat_pump`, `gas`, `district`, ... Show it translated. |
|
|
168
|
+
| `has_cellar`, `furnished`, `pets_allowed` | `true` or `false`. |
|
|
169
|
+
| `indoor_parkings`, `outdoor_parkings` | Numbers of parkings. |
|
|
170
|
+
| `charges` | Monthly charges of the property. |
|
|
171
|
+
| `rent_extra` | Rentals: monthly extra costs on top of `price`. |
|
|
172
|
+
| `parking_price` or `indoor_parking_price` / `outdoor_parking_price` | All parkings together, or per parking. `parking_included: true` when they are in `price`. |
|
|
173
|
+
| `available_from` | Move-in date, `YYYY-MM-DD`. |
|
|
174
|
+
| `video_url`, `virtual_visit_url` | Links. A unit has them, like its photos, when it is a live listing. |
|
|
175
|
+
|
|
176
|
+
A unit also has `reference` (its number in the development, `A1.2`) and
|
|
177
|
+
`site_plan_points`: its outline on the development's `site_plan`, as SVG
|
|
178
|
+
polygon points in the image's own pixels
|
|
179
|
+
(`<svg viewBox="0 0 {width} {height}"><polygon points={site_plan_points} /></svg>`
|
|
180
|
+
over the image). A development has `developer`, `architect` and
|
|
181
|
+
`general_contractor`: `{ name, website?, logo? }`.
|
|
182
|
+
|
|
183
|
+
`Unit.name` ("4,5 pièces · Rez-de-jardin") is written by the API in French for
|
|
184
|
+
now, whatever the site's language.
|
|
185
|
+
|
|
186
|
+
All types are exported from the package root and from
|
|
187
|
+
`@realadvisor/sites-sdk/types`:
|
|
188
|
+
`Organisation`, `Team`, `Agent`, `Listing`, `Unit`, `Development`,
|
|
189
|
+
`DevelopmentWithUnits`, `PropertyFacts`, `Company`, `Photo`, `Address`,
|
|
190
|
+
`Socials`, `OfferType`, `ListingStatus`, `UnitStatus`, `Page`, `Pagination`,
|
|
191
|
+
`ListingsQuery`, `TextQuery`, `Portals`, `ClientOptions`, `SitesClient`,
|
|
192
|
+
`Fixture`, `FixtureTranslations`, `ErrorCode`.
|
|
193
|
+
|
|
194
|
+
## Enquiries
|
|
195
|
+
|
|
196
|
+
`handleEnquiry` is the site's whole enquiry endpoint: a standard `Request` in,
|
|
197
|
+
a `Response` out. Mount it on one route.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
// Astro: src/pages/api/enquiry.ts
|
|
201
|
+
import { handleEnquiry } from '@realadvisor/sites-sdk';
|
|
202
|
+
import { ra } from '../../lib/ra';
|
|
203
|
+
|
|
204
|
+
export const prerender = false;
|
|
205
|
+
export const POST = ({ request }) => handleEnquiry(request, ra);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
It accepts a JSON body or a posted form with these fields:
|
|
209
|
+
|
|
210
|
+
| Field | Required | Notes |
|
|
211
|
+
| --- | --- | --- |
|
|
212
|
+
| `name`, `email`, `message` | yes | At most 100, 200 and 5000 characters. |
|
|
213
|
+
| `phone` | no | At most 40 characters. |
|
|
214
|
+
| `listing_id`, `agent_id`, `team_id` | no | What the enquiry is about. |
|
|
215
|
+
| `development_id`, `lot_id` | no | `lot_id` needs `development_id`. |
|
|
216
|
+
| `contact_reason` | no | `buy`, `sell`, `valuation` or `other`. |
|
|
217
|
+
| `locale`, `page_url` | no | `page_url` defaults to the `Referer` header. |
|
|
218
|
+
| `idempotency_key` | no | 8 to 100 letters, digits, `-` or `_`. The same key never creates a second enquiry. |
|
|
219
|
+
| `cf-turnstile-response` | when verification is on | The Turnstile widget adds it to the form. |
|
|
220
|
+
|
|
221
|
+
It answers:
|
|
222
|
+
|
|
223
|
+
| Case | JSON request | Form posted without JavaScript |
|
|
224
|
+
| --- | --- | --- |
|
|
225
|
+
| Sent | `200 { id }` | `303` to `redirect` with `?enquiry=sent` |
|
|
226
|
+
| Preview (`SITE_MODE=preview`) | `200 { preview: true }`, nothing is sent | `?enquiry=preview` |
|
|
227
|
+
| Refused | the code's status, `{ code, fields? }` | `?enquiry=error` |
|
|
228
|
+
|
|
229
|
+
`redirect` is a form field: a path of the same site (default `/`).
|
|
230
|
+
|
|
231
|
+
The answer carries codes, never sentences: the site's form words them in its
|
|
232
|
+
own language. The visitor's IP (`cf-connecting-ip`, else `x-forwarded-for`) is
|
|
233
|
+
passed on so the API can limit enquiries per visitor.
|
|
234
|
+
|
|
235
|
+
### Verification (Turnstile)
|
|
236
|
+
|
|
237
|
+
The API decides whether an enquiry needs a Turnstile token. Ask it for the
|
|
238
|
+
public key the form's widget renders:
|
|
239
|
+
|
|
240
|
+
```
|
|
241
|
+
GET /v3/public/enquiries/verification -> { "site_key": "0x4AAA..." }
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`site_key` is `null` while verification is off: render no widget then.
|
|
245
|
+
`turnstileSiteKey()` reads it and keeps it 5 minutes (30 s while there is
|
|
246
|
+
none); a failed lookup answers `null`.
|
|
247
|
+
|
|
248
|
+
## Errors
|
|
249
|
+
|
|
250
|
+
Every failure is a `SitesApiError` with one `code`:
|
|
251
|
+
|
|
252
|
+
| `code` | Status | Meaning |
|
|
253
|
+
| --- | --- | --- |
|
|
254
|
+
| `invalid` | 400 | A field is wrong. `err.fields` says which, e.g. `{ email: 'invalid' }`. |
|
|
255
|
+
| `invalid_portal` | 400 | A portal is not an API portal of the organisation. |
|
|
256
|
+
| `invalid_team` | 400 | `team_id` is not a team of the organisation. |
|
|
257
|
+
| `rejected` | 400 | The CRM refused the enquiry. |
|
|
258
|
+
| `site_key` | 401, 403 | The key was revoked or rotated, or may not read this. |
|
|
259
|
+
| `verification_required` | 403 | No valid Turnstile token. |
|
|
260
|
+
| `rate_limited` | 429 | Too many enquiries from this visitor. |
|
|
261
|
+
| `unavailable` | any other, or none | The API failed or did not answer in 8 s, or no data access is configured. Also a 409: an enquiry with the same `idempotency_key` is still being created. |
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { SitesApiError } from '@realadvisor/sites-sdk';
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await ra.listings({ team_id });
|
|
268
|
+
} catch (err) {
|
|
269
|
+
if (err instanceof SitesApiError && err.code === 'invalid_team') {
|
|
270
|
+
// err.status is the HTTP status (0 when the API gave none), err.fields the wrong fields
|
|
271
|
+
}
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
A read that finds the API unavailable is tried once more before it fails. An
|
|
277
|
+
enquiry is never retried.
|
|
278
|
+
|
|
279
|
+
## Caching
|
|
280
|
+
|
|
281
|
+
The API caches reads for 60 seconds per key. A change in the CRM shows on the
|
|
282
|
+
site within a minute. Do not add a cache of your own on top. Enquiries and the
|
|
283
|
+
verification route are never cached.
|
|
284
|
+
|
|
285
|
+
## Fixtures
|
|
286
|
+
|
|
287
|
+
`createFixtureClient(fixture)` is a `SitesClient` with no network, for
|
|
288
|
+
`pnpm dev`, builds and tests: the same methods and the same filters as the
|
|
289
|
+
API, over a `Fixture` (`organisation`, `teams`, `agents`, `listings`,
|
|
290
|
+
`developments`, in the API's shape). `turnstileSiteKey()` answers
|
|
291
|
+
`FIXTURE_TURNSTILE_SITE_KEY` (a stand-in widget that never loads Turnstile)
|
|
292
|
+
unless the fixture sets `turnstile_site_key`. It sends no enquiry:
|
|
293
|
+
`handleEnquiry` refuses it, so answer a fixture-mode form yourself.
|
|
294
|
+
|
|
295
|
+
A fixture listing or unit may carry its texts in other languages:
|
|
296
|
+
|
|
297
|
+
```json
|
|
298
|
+
{ "title": "Villa du lac", "translations": { "de": { "title": "Villa am See", "description": "<p>Seesicht</p>" } } }
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
With `locale`, the fixture client serves them as the API serves the CRM's
|
|
302
|
+
(`text_locale` included; a unit's description only when it has one), and it
|
|
303
|
+
never passes `translations` on.
|
|
304
|
+
|
|
305
|
+
## Tests
|
|
306
|
+
|
|
307
|
+
`createClient` takes `url`, `key` and `fetch` for tests only:
|
|
308
|
+
|
|
309
|
+
```ts
|
|
310
|
+
const ra = createClient({
|
|
311
|
+
portals: { main: 'p1' },
|
|
312
|
+
url: 'https://api.test',
|
|
313
|
+
key: 'test',
|
|
314
|
+
fetch: async () => Response.json({ id: 'o1', name: 'Demo Agency' }),
|
|
315
|
+
});
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
```sh
|
|
319
|
+
cd sdk && pnpm test # node --test, no network
|
|
320
|
+
cd rest-api && pnpm check # includes the test that SDK types equal the /v3/public schemas
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
`rest-api/scripts/sites-sdk-smoke.mjs` runs the SDK against a live API; CI
|
|
324
|
+
runs it on every branch preview.
|
|
325
|
+
|
|
326
|
+
## Reference
|
|
327
|
+
|
|
328
|
+
- REST contract: `rest-api/src/routes/public-schemas.ts`, served as OpenAPI at
|
|
329
|
+
`/v3/openapi.json`.
|
|
330
|
+
- Types: [`src/types.ts`](src/types.ts).
|
|
331
|
+
- Client: [`src/index.ts`](src/index.ts).
|
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@realadvisor-sites/sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "The data client of every RealAdvisor site: createClient, createFixtureClient, handleEnquiry, SitesApiError and the types. No dependencies, web standards only: it runs unchanged in Astro, React Router and plain Node.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/realadvisor/sites.git",
|
|
9
|
+
"directory": "sdk"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/realadvisor/sites/tree/main/sdk#readme",
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
16
|
+
],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public",
|
|
19
|
+
"provenance": true
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": "./src/index.ts",
|
|
24
|
+
"./types": "./src/types.ts",
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"check": "pnpm test",
|
|
29
|
+
"test": "node --experimental-strip-types --no-warnings=ExperimentalWarning --test \"test/*.test.ts\""
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22.18"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @realadvisor/sites-sdk: how a RealAdvisor site reads its data. One URL and
|
|
3
|
+
* one key (SITES_API_URL, SITES_API_KEY in process.env) in every framework
|
|
4
|
+
* and environment; rest-api owns every data rule, the SDK adds transport,
|
|
5
|
+
* types, `err.code` and the enquiry endpoint. Web standards only.
|
|
6
|
+
*/
|
|
7
|
+
import type {
|
|
8
|
+
Agent,
|
|
9
|
+
Fixture,
|
|
10
|
+
FixtureTranslations,
|
|
11
|
+
ClientOptions,
|
|
12
|
+
Development,
|
|
13
|
+
DevelopmentWithUnits,
|
|
14
|
+
ErrorCode,
|
|
15
|
+
Listing,
|
|
16
|
+
ListingsQuery,
|
|
17
|
+
Organisation,
|
|
18
|
+
Page,
|
|
19
|
+
SitesClient,
|
|
20
|
+
Team,
|
|
21
|
+
TextQuery,
|
|
22
|
+
Unit,
|
|
23
|
+
} from './types.ts';
|
|
24
|
+
|
|
25
|
+
export type * from './types.ts';
|
|
26
|
+
|
|
27
|
+
const TIMEOUT_MS = 8000;
|
|
28
|
+
const PAGE = 200;
|
|
29
|
+
/** How long turnstileSiteKey() keeps a key (30 s when there is none). */
|
|
30
|
+
const TURNSTILE_TTL_MS = 5 * 60 * 1000;
|
|
31
|
+
/** The last offset rest-api accepts (its MAX_OFFSET). */
|
|
32
|
+
const MAX_OFFSET = 10_000;
|
|
33
|
+
const NO_ACCESS = 'no data access: set SITES_API_URL and SITES_API_KEY';
|
|
34
|
+
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
35
|
+
|
|
36
|
+
/** Status `handleEnquiry` answers for each code. */
|
|
37
|
+
const STATUS: Record<ErrorCode, number> = {
|
|
38
|
+
invalid: 400,
|
|
39
|
+
invalid_portal: 400,
|
|
40
|
+
invalid_team: 400,
|
|
41
|
+
rejected: 400,
|
|
42
|
+
site_key: 401,
|
|
43
|
+
verification_required: 403,
|
|
44
|
+
rate_limited: 429,
|
|
45
|
+
unavailable: 502,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export class SitesApiError extends Error {
|
|
49
|
+
readonly code: ErrorCode;
|
|
50
|
+
/** The HTTP status the API answered, 0 when none (timeout, network, no data access). */
|
|
51
|
+
readonly status: number;
|
|
52
|
+
/** `invalid`: the wrong fields, e.g. `{ email: 'invalid' }`. */
|
|
53
|
+
readonly fields?: Record<string, string>;
|
|
54
|
+
|
|
55
|
+
constructor(code: ErrorCode, message: string, opts: { status?: number; fields?: Record<string, string> } = {}) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'SitesApiError';
|
|
58
|
+
this.code = code;
|
|
59
|
+
this.status = opts.status ?? 0;
|
|
60
|
+
if (opts.fields) this.fields = opts.fields;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The one code of an API failure, from its status and the body's `error`. */
|
|
65
|
+
const codeOf = (status: number, error: unknown): ErrorCode => {
|
|
66
|
+
if (status === 400) {
|
|
67
|
+
return error === 'invalid_portal' || error === 'invalid_team' || error === 'rejected' ? error : 'invalid';
|
|
68
|
+
}
|
|
69
|
+
if (status === 401) return 'site_key';
|
|
70
|
+
if (status === 403) {
|
|
71
|
+
// Any other 403 (a key that may not read this) is the key's.
|
|
72
|
+
return error === 'verification_required' ? 'verification_required' : 'site_key';
|
|
73
|
+
}
|
|
74
|
+
if (status === 429) return 'rate_limited';
|
|
75
|
+
return 'unavailable';
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const env = (name: string): string | undefined => {
|
|
79
|
+
const proc = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process;
|
|
80
|
+
return proc?.env?.[name]?.trim() || undefined;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Sends the enquiry `handleEnquiry` built: kept off the client's public methods. */
|
|
84
|
+
const SEND = Symbol('sites-sdk.send');
|
|
85
|
+
|
|
86
|
+
type Send = (body: Record<string, unknown>, clientIp?: string) => Promise<{ id: string }>;
|
|
87
|
+
|
|
88
|
+
const query = (q: Record<string, unknown>) => {
|
|
89
|
+
const params = new URLSearchParams();
|
|
90
|
+
for (const [k, v] of Object.entries(q)) if (v != null && v !== '') params.set(k, String(v));
|
|
91
|
+
const s = params.toString();
|
|
92
|
+
return s ? `?${s}` : '';
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const createClient = (opts: ClientOptions = {}): SitesClient => {
|
|
96
|
+
const portals = opts.portals;
|
|
97
|
+
// Read per call: a Worker's process.env is set by then, and a build
|
|
98
|
+
// without data access only fails where it reads data.
|
|
99
|
+
const access = () => {
|
|
100
|
+
const url = opts.url ?? env('SITES_API_URL');
|
|
101
|
+
const key = opts.key ?? env('SITES_API_KEY');
|
|
102
|
+
if (!url || !key) throw new SitesApiError('unavailable', NO_ACCESS);
|
|
103
|
+
return { root: url.replace(/\/+$/, ''), key };
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const once = async (method: string, path: string, body?: unknown, headers: Record<string, string> = {}) => {
|
|
107
|
+
const { root, key } = access();
|
|
108
|
+
const doFetch = opts.fetch ?? globalThis.fetch.bind(globalThis);
|
|
109
|
+
const abort = new AbortController();
|
|
110
|
+
const timer = setTimeout(() => abort.abort(), TIMEOUT_MS);
|
|
111
|
+
let res: Response;
|
|
112
|
+
try {
|
|
113
|
+
res = await doFetch(`${root}${path}`, {
|
|
114
|
+
method,
|
|
115
|
+
headers: {
|
|
116
|
+
accept: 'application/json',
|
|
117
|
+
authorization: `Bearer ${key}`,
|
|
118
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
119
|
+
...headers,
|
|
120
|
+
},
|
|
121
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
122
|
+
signal: abort.signal,
|
|
123
|
+
});
|
|
124
|
+
} catch (err) {
|
|
125
|
+
const timedOut = abort.signal.aborted;
|
|
126
|
+
throw new SitesApiError('unavailable', `${method} ${path}: ${timedOut ? `no answer in ${TIMEOUT_MS / 1000} s` : (err as Error).message}`);
|
|
127
|
+
} finally {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
}
|
|
130
|
+
return res;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/** One API call: JSON, or null on 404 when `orNull`. A GET that finds the API unavailable is tried once more. */
|
|
134
|
+
const call = async <T>(method: 'GET' | 'POST', path: string, init: { body?: unknown; headers?: Record<string, string>; orNull?: boolean } = {}): Promise<T> => {
|
|
135
|
+
for (let attempt = 0; ; attempt++) {
|
|
136
|
+
try {
|
|
137
|
+
const res = await once(method, path, init.body, init.headers);
|
|
138
|
+
if (res.ok) return (await res.json()) as T;
|
|
139
|
+
if (res.status === 404 && init.orNull) return null as T;
|
|
140
|
+
const err = (await res.json().catch(() => ({}))) as { error?: unknown; fields?: Record<string, string>; detail?: string; title?: string };
|
|
141
|
+
const code = codeOf(res.status, err.error);
|
|
142
|
+
throw new SitesApiError(code, `${method} ${path}: ${res.status} ${err.detail ?? err.title ?? (typeof err.error === 'string' ? err.error : code)}`, {
|
|
143
|
+
status: res.status,
|
|
144
|
+
fields: err.fields,
|
|
145
|
+
});
|
|
146
|
+
} catch (err) {
|
|
147
|
+
const retry = method === 'GET' && attempt === 0 && err instanceof SitesApiError && err.code === 'unavailable' && err.message !== NO_ACCESS;
|
|
148
|
+
if (!retry) throw err;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** A portal by name; without `portals`, none: rest-api uses the site's own. */
|
|
154
|
+
const portalId = (name?: string) => {
|
|
155
|
+
if (!portals && name === undefined) return undefined;
|
|
156
|
+
const id = portals?.[name ?? 'main'];
|
|
157
|
+
if (!id) throw new SitesApiError('invalid_portal', `portal "${name}" is not in the client's portals`, { status: 400 });
|
|
158
|
+
return id;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/** Every portal of the client: a listing or unit live on any of them shows. */
|
|
162
|
+
const allPortals = () => (portals ? [...new Set(Object.values(portals))].join(',') : undefined);
|
|
163
|
+
|
|
164
|
+
let turnstile: { at: number; key: Promise<string | null> } | undefined;
|
|
165
|
+
const turnstileSiteKey = () => {
|
|
166
|
+
if (!turnstile || Date.now() - turnstile.at > TURNSTILE_TTL_MS) {
|
|
167
|
+
const key = call<{ site_key?: unknown }>('GET', '/v3/public/enquiries/verification').then(
|
|
168
|
+
res => (typeof res?.site_key === 'string' && res.site_key ? res.site_key : null),
|
|
169
|
+
(err: unknown) => {
|
|
170
|
+
// A 403 (a key that may not read this): no widget; rest-api still decides on the enquiry.
|
|
171
|
+
if (!(err instanceof SitesApiError && err.code === 'site_key' && err.status === 403)) {
|
|
172
|
+
console.error(`[sites-sdk] turnstile site key lookup failed: ${(err as Error).message}`);
|
|
173
|
+
}
|
|
174
|
+
return null;
|
|
175
|
+
},
|
|
176
|
+
);
|
|
177
|
+
const entry = { at: Date.now(), key };
|
|
178
|
+
// No key: ask again after 30 s, not 5 min.
|
|
179
|
+
void key.then(k => {
|
|
180
|
+
if (!k) entry.at = Date.now() - TURNSTILE_TTL_MS + 30_000;
|
|
181
|
+
});
|
|
182
|
+
turnstile = entry;
|
|
183
|
+
}
|
|
184
|
+
return turnstile.key;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const listings = async ({ portal, limit, ...q }: ListingsQuery = {}) => {
|
|
188
|
+
const n = Math.min(Math.max(Math.trunc(limit ?? 24), 1), PAGE);
|
|
189
|
+
return call<Page<Listing>>('GET', `/v3/public/listings${query({ ...q, portal_ids: portalId(portal), limit: n })}`);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Every row of a list: the first page gives the total, the others are read
|
|
194
|
+
* at once. It ends with the last page rest-api serves (offset MAX_OFFSET).
|
|
195
|
+
*/
|
|
196
|
+
const all = async <T>(page: (limit: number, offset: number) => Promise<Page<T>>) => {
|
|
197
|
+
const first = await page(PAGE, 0);
|
|
198
|
+
const offsets: number[] = [];
|
|
199
|
+
for (let offset = PAGE; offset < first.pagination.total && offset <= MAX_OFFSET; offset += PAGE) offsets.push(offset);
|
|
200
|
+
const rest = await Promise.all(offsets.map(offset => page(PAGE, offset)));
|
|
201
|
+
return [first, ...rest].flatMap(p => p.data);
|
|
202
|
+
};
|
|
203
|
+
const list = <T>(path: string, q: Record<string, unknown> = {}) =>
|
|
204
|
+
all((limit, offset) => call<Page<T>>('GET', `${path}${query({ ...q, limit, offset })}`));
|
|
205
|
+
|
|
206
|
+
const client: SitesClient & { [SEND]: Send } = {
|
|
207
|
+
organisation: () => call<Organisation>('GET', '/v3/public/organisation'),
|
|
208
|
+
teams: () => list<Team>('/v3/public/teams'),
|
|
209
|
+
agents: (q = {}) => list<Agent>('/v3/public/agents', q),
|
|
210
|
+
listings,
|
|
211
|
+
allListings: (q = {}) => all((limit, offset) => listings({ ...q, limit, offset })),
|
|
212
|
+
listing: async (slugOrId, q = {}) => {
|
|
213
|
+
const id = slugOrId.match(UUID_RE)?.[0].toLowerCase();
|
|
214
|
+
if (!id) return null;
|
|
215
|
+
return call<Listing | null>('GET', `/v3/public/listings/${id}${query({ portal_ids: allPortals(), locale: q.locale })}`, { orNull: true });
|
|
216
|
+
},
|
|
217
|
+
developments: (q = {}) => list<Development>('/v3/public/developments', { ...q, portal_ids: allPortals() }),
|
|
218
|
+
development: (slugOrId, q = {}) =>
|
|
219
|
+
call<DevelopmentWithUnits | null>(
|
|
220
|
+
'GET',
|
|
221
|
+
`/v3/public/developments/${encodeURIComponent(slugOrId)}${query({ portal_ids: allPortals(), locale: q.locale })}`,
|
|
222
|
+
{ orNull: true },
|
|
223
|
+
),
|
|
224
|
+
turnstileSiteKey,
|
|
225
|
+
[SEND]: (body, clientIp) =>
|
|
226
|
+
call<{ id: string }>('POST', '/v3/public/enquiries', {
|
|
227
|
+
body: { ...body, portal_id: portalId() },
|
|
228
|
+
headers: clientIp ? { 'x-ra-client-ip': clientIp } : {},
|
|
229
|
+
}),
|
|
230
|
+
};
|
|
231
|
+
return client;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
235
|
+
const LIMITS: Record<string, number> = { name: 100, email: 200, phone: 40, message: 5000 };
|
|
236
|
+
const REASONS = new Set(['buy', 'sell', 'valuation', 'other']);
|
|
237
|
+
const PASSED = ['phone', 'listing_id', 'agent_id', 'development_id', 'lot_id', 'team_id', 'contact_reason', 'locale', 'page_url', 'idempotency_key'];
|
|
238
|
+
|
|
239
|
+
const readBody = async (request: Request): Promise<Record<string, string>> => {
|
|
240
|
+
const out: Record<string, string> = {};
|
|
241
|
+
if ((request.headers.get('content-type') ?? '').includes('application/json')) {
|
|
242
|
+
const json = (await request.json().catch(() => null)) as Record<string, unknown> | null;
|
|
243
|
+
for (const [k, v] of Object.entries(json ?? {})) if (typeof v === 'string') out[k] = v;
|
|
244
|
+
} else {
|
|
245
|
+
const form = await request.formData().catch(() => null);
|
|
246
|
+
for (const [k, v] of form ?? []) if (typeof v === 'string') out[k] = v;
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The site's whole enquiry endpoint: a standard Request in, a Response out.
|
|
253
|
+
* JSON in, JSON out: `{ id }`, `{ preview: true }` (SITE_MODE=preview: never
|
|
254
|
+
* sent) or `{ code, fields? }` with the code's status. Fields are named as
|
|
255
|
+
* rest-api names them (`listing_id`, `contact_reason`, ...). A form posted without
|
|
256
|
+
* JavaScript gets a 303 back to its `redirect` with `?enquiry=sent|preview|error`.
|
|
257
|
+
* Codes only: the site's form words them in its language.
|
|
258
|
+
*/
|
|
259
|
+
export const handleEnquiry = async (request: Request, ra: SitesClient): Promise<Response> => {
|
|
260
|
+
const body = await readBody(request);
|
|
261
|
+
const field = (k: string) => body[k]?.trim() || undefined;
|
|
262
|
+
const wantsJson =
|
|
263
|
+
(request.headers.get('accept') ?? '').includes('application/json') ||
|
|
264
|
+
(request.headers.get('content-type') ?? '').includes('application/json');
|
|
265
|
+
const respond = (status: number, json: Record<string, unknown>, state: 'sent' | 'preview' | 'error') => {
|
|
266
|
+
if (wantsJson) return Response.json(json, { status });
|
|
267
|
+
const back = field('redirect') ?? '/';
|
|
268
|
+
// Only a same-site path; the state goes before the #anchor.
|
|
269
|
+
const target = new URL(back.startsWith('/') && !back.startsWith('//') ? back : '/', 'http://site');
|
|
270
|
+
target.searchParams.set('enquiry', state);
|
|
271
|
+
return new Response(null, { status: 303, headers: { location: target.pathname + target.search + target.hash } });
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
const fields: Record<string, string> = {};
|
|
275
|
+
for (const k of ['name', 'email', 'message']) if (!field(k)) fields[k] = 'required';
|
|
276
|
+
if (field('email') && !EMAIL_RE.test(field('email')!)) fields.email = 'invalid';
|
|
277
|
+
for (const [k, max] of Object.entries(LIMITS)) if ((field(k)?.length ?? 0) > max) fields[k] = 'too_long';
|
|
278
|
+
if (field('contact_reason') && !REASONS.has(field('contact_reason')!)) fields.contact_reason = 'invalid';
|
|
279
|
+
if (field('lot_id') && !field('development_id')) fields.lot_id = 'invalid';
|
|
280
|
+
if (Object.keys(fields).length) return respond(400, { code: 'invalid', fields }, 'error');
|
|
281
|
+
|
|
282
|
+
if (env('SITE_MODE') === 'preview') return respond(200, { preview: true }, 'preview');
|
|
283
|
+
|
|
284
|
+
const input: Record<string, unknown> = { name: field('name'), email: field('email'), message: field('message') };
|
|
285
|
+
for (const k of PASSED) if (field(k)) input[k] = field(k);
|
|
286
|
+
input.page_url ??= request.headers.get('referer') ?? undefined;
|
|
287
|
+
const token = field('cf-turnstile-response') ?? field('turnstile_token');
|
|
288
|
+
if (token) input.turnstile_token = token;
|
|
289
|
+
const clientIp =
|
|
290
|
+
request.headers.get('cf-connecting-ip')?.trim() || request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || undefined;
|
|
291
|
+
|
|
292
|
+
const send = (ra as SitesClient & { [SEND]?: Send })[SEND];
|
|
293
|
+
if (!send) throw new TypeError('handleEnquiry needs a client from createClient');
|
|
294
|
+
try {
|
|
295
|
+
const { id } = await send(input, clientIp);
|
|
296
|
+
return respond(200, { id }, 'sent');
|
|
297
|
+
} catch (err) {
|
|
298
|
+
if (!(err instanceof SitesApiError)) throw err;
|
|
299
|
+
if (err.code === 'unavailable' || err.code === 'site_key') console.error(`[sites-sdk] enquiry: ${err.message}`);
|
|
300
|
+
return respond(STATUS[err.code], { code: err.code, ...(err.fields ? { fields: err.fields } : {}) }, 'error');
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
/** turnstileSiteKey() of a fixture: the form renders a stand-in widget and never loads Turnstile. */
|
|
305
|
+
export const FIXTURE_TURNSTILE_SITE_KEY = 'fixture';
|
|
306
|
+
|
|
307
|
+
/** The language of a locale (`de-CH` → `de`), as rest-api reads it. */
|
|
308
|
+
const languageOf = (locale?: string): string | undefined => locale?.trim().toLowerCase().split(/[-_]/)[0] || undefined;
|
|
309
|
+
|
|
310
|
+
const plainText = (html: string): string =>
|
|
311
|
+
html
|
|
312
|
+
.replace(/<\/(p|li|h3|h4)>|<br\s*\/?>/gi, '$& ')
|
|
313
|
+
.replace(/<[^>]*>/g, '')
|
|
314
|
+
.replace(/\s+/g, ' ')
|
|
315
|
+
.trim();
|
|
316
|
+
|
|
317
|
+
type Texts = { title?: string; description?: string; description_text?: string; text_locale?: string };
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* A fixture record as the API serves it in `locale`: its `translations` in
|
|
321
|
+
* that language replace the title (listings) and the description (a unit's
|
|
322
|
+
* only when it has one: live units), `text_locale` set when every text
|
|
323
|
+
* served is translated. `translations` never leaves the client.
|
|
324
|
+
*/
|
|
325
|
+
const inLocale = <T extends Texts>(
|
|
326
|
+
{ translations, ...rest }: T & FixtureTranslations,
|
|
327
|
+
locale: string | undefined,
|
|
328
|
+
kind: 'listing' | 'unit',
|
|
329
|
+
): T => {
|
|
330
|
+
const record = rest as unknown as T;
|
|
331
|
+
const lang = languageOf(locale);
|
|
332
|
+
const t = lang ? translations?.[lang] : undefined;
|
|
333
|
+
if (!lang || !t) return record;
|
|
334
|
+
const out: T = { ...record };
|
|
335
|
+
const title = kind === 'listing' && !!t.title;
|
|
336
|
+
const description = !!t.description && (kind === 'listing' || record.description !== undefined);
|
|
337
|
+
if (title) out.title = t.title;
|
|
338
|
+
if (description) {
|
|
339
|
+
out.description = t.description;
|
|
340
|
+
out.description_text = t.description_text ?? plainText(t.description!);
|
|
341
|
+
}
|
|
342
|
+
const translated = (kind === 'unit' || title) && (description || out.description === undefined);
|
|
343
|
+
if (translated && (title || description)) out.text_locale = lang;
|
|
344
|
+
return out;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const fold = (s = ''): string => s.normalize('NFD').replace(/[̀-ͯ]/g, '').trim().toLowerCase();
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* A SitesClient that answers from a fixture instead of rest-api: the same
|
|
351
|
+
* interface and the same filters as createClient, so a page written against
|
|
352
|
+
* it runs unchanged on live data. For `pnpm dev`, builds and tests without a
|
|
353
|
+
* key. It sends nothing: handleEnquiry refuses it.
|
|
354
|
+
*/
|
|
355
|
+
export const createFixtureClient = (fx: Fixture): SitesClient => {
|
|
356
|
+
const match = (l: Listing, q: ListingsQuery) =>
|
|
357
|
+
(!q.team_id || l.team_ids.includes(q.team_id)) &&
|
|
358
|
+
(!q.offer_type || l.offer_type === q.offer_type) &&
|
|
359
|
+
(!q.property_type || l.property_type === q.property_type || l.property_type.startsWith(`${q.property_type}_`)) &&
|
|
360
|
+
(!q.status || l.status === q.status) &&
|
|
361
|
+
(!q.city || fold(l.address.city) === fold(q.city)) &&
|
|
362
|
+
(q.min_price == null || (l.price != null && l.price >= q.min_price)) &&
|
|
363
|
+
(q.max_price == null || (l.price != null && l.price <= q.max_price)) &&
|
|
364
|
+
(q.min_rooms == null || (l.rooms != null && l.rooms >= q.min_rooms));
|
|
365
|
+
// Newest first, as rest-api lists them.
|
|
366
|
+
const found = (q: ListingsQuery) =>
|
|
367
|
+
[...fx.listings]
|
|
368
|
+
.sort((a, b) => b.published_at.localeCompare(a.published_at))
|
|
369
|
+
.filter(l => match(l, q))
|
|
370
|
+
.map(l => inLocale<Listing>(l, q.locale, 'listing'));
|
|
371
|
+
const listings = async (q: ListingsQuery = {}): Promise<Page<Listing>> => {
|
|
372
|
+
const all = found(q);
|
|
373
|
+
const limit = Math.min(Math.max(Math.trunc(q.limit ?? 24), 1), 200);
|
|
374
|
+
const offset = Math.max(Math.trunc(q.offset ?? 0), 0);
|
|
375
|
+
const data = all.slice(offset, offset + limit);
|
|
376
|
+
return { data, pagination: { limit, offset, total: all.length, total_is_capped: false, has_more: offset + data.length < all.length } };
|
|
377
|
+
};
|
|
378
|
+
const developments = (q: TextQuery = {}): DevelopmentWithUnits[] =>
|
|
379
|
+
(fx.developments ?? []).map(d => ({ ...d, units: d.units.map(u => inLocale<Unit>(u, q.locale, 'unit')) }));
|
|
380
|
+
const strip = ({ units: _units, ...d }: DevelopmentWithUnits): Development => d;
|
|
381
|
+
const byKey = <T extends { id: string; slug: string }>(arr: T[], s: string) => {
|
|
382
|
+
const id = s.match(UUID_RE)?.[0].toLowerCase();
|
|
383
|
+
return arr.find(x => x.slug === s || x.id === s || (!!id && x.id === id)) ?? null;
|
|
384
|
+
};
|
|
385
|
+
return {
|
|
386
|
+
organisation: async () => fx.organisation,
|
|
387
|
+
teams: async () => fx.teams,
|
|
388
|
+
agents: async (q = {}) => (q.team_id ? fx.agents.filter(a => a.team_ids.includes(q.team_id!)) : fx.agents),
|
|
389
|
+
listings,
|
|
390
|
+
allListings: async (q = {}) => found(q),
|
|
391
|
+
listing: async (s, q = {}) => {
|
|
392
|
+
const l = byKey(fx.listings, s);
|
|
393
|
+
return l && inLocale<Listing>(l, q.locale, 'listing');
|
|
394
|
+
},
|
|
395
|
+
developments: async (q = {}) => developments(q).filter(d => !q.team_id || d.team_ids.includes(q.team_id)).map(strip),
|
|
396
|
+
development: async (s, q = {}) => byKey(developments(q), s),
|
|
397
|
+
turnstileSiteKey: async () => (fx.turnstile_site_key === undefined ? FIXTURE_TURNSTILE_SITE_KEY : fx.turnstile_site_key),
|
|
398
|
+
};
|
|
399
|
+
};
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What @realadvisor/sites-sdk returns: the `/v3/public/*` contract of rest-api
|
|
3
|
+
* (src/routes/public-schemas.ts), field for field and in its snake_case. The
|
|
4
|
+
* SDK passes answers through; rest-api/test/public-sdk-types.test.ts fails
|
|
5
|
+
* when these types and the schemas differ. A field with no value is absent,
|
|
6
|
+
* arrays are arrays, descriptions are sanitized HTML, coordinates are never
|
|
7
|
+
* exact.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type Photo = { url: string; alt: string };
|
|
11
|
+
|
|
12
|
+
export type Address = {
|
|
13
|
+
street?: string;
|
|
14
|
+
postcode?: string;
|
|
15
|
+
city?: string;
|
|
16
|
+
country?: string;
|
|
17
|
+
/** Rounded by the listing's address display option (about 10 m, 100 m or 1 km). */
|
|
18
|
+
lat?: number;
|
|
19
|
+
lng?: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type Socials = {
|
|
23
|
+
facebook?: string;
|
|
24
|
+
instagram?: string;
|
|
25
|
+
linkedin?: string;
|
|
26
|
+
x?: string;
|
|
27
|
+
youtube?: string;
|
|
28
|
+
tiktok?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** The organisation the site is for (an agency with one or more teams). */
|
|
32
|
+
export type Organisation = {
|
|
33
|
+
id: string;
|
|
34
|
+
name: string;
|
|
35
|
+
logo?: Photo;
|
|
36
|
+
website?: string;
|
|
37
|
+
address?: Address;
|
|
38
|
+
brand?: { primary: string };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** One team (office, branch) of the organisation. */
|
|
42
|
+
export type Team = {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
/** The site's own team: first in `teams()`. */
|
|
46
|
+
primary?: boolean;
|
|
47
|
+
logo?: Photo;
|
|
48
|
+
website?: string;
|
|
49
|
+
phone?: string;
|
|
50
|
+
email?: string;
|
|
51
|
+
address: Address;
|
|
52
|
+
socials: Socials;
|
|
53
|
+
/** Sanitized HTML, same allowlist as Listing.description. */
|
|
54
|
+
description?: string;
|
|
55
|
+
description_text?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type Agent = {
|
|
59
|
+
id: string;
|
|
60
|
+
first_name: string;
|
|
61
|
+
last_name: string;
|
|
62
|
+
title?: string;
|
|
63
|
+
phone?: string;
|
|
64
|
+
email?: string;
|
|
65
|
+
photo?: Photo;
|
|
66
|
+
languages?: string[];
|
|
67
|
+
bio?: string;
|
|
68
|
+
/** The agent's active teams. */
|
|
69
|
+
team_ids: string[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type OfferType = 'buy' | 'rent';
|
|
73
|
+
export type ListingStatus = 'available' | 'sold';
|
|
74
|
+
|
|
75
|
+
/** The facts a listing and a unit share. Money is in the record's `currency`, surfaces in m². */
|
|
76
|
+
export type PropertyFacts = {
|
|
77
|
+
/** Set when title and description came in the `locale` asked for; absent: the original texts. Mark it as the element's `lang`. */
|
|
78
|
+
text_locale?: string;
|
|
79
|
+
balcony_surface?: number;
|
|
80
|
+
terrace_surface?: number;
|
|
81
|
+
garden_surface?: number;
|
|
82
|
+
/** 0 = ground floor. */
|
|
83
|
+
floor?: number;
|
|
84
|
+
/** Floors of the building. */
|
|
85
|
+
building_floors?: number;
|
|
86
|
+
/** Directions the property faces. */
|
|
87
|
+
orientation?: Array<'north' | 'east' | 'south' | 'west'>;
|
|
88
|
+
construction_year?: number;
|
|
89
|
+
renovation_year?: number;
|
|
90
|
+
/** Energy consumption class of the certificate. */
|
|
91
|
+
energy_class?: 'A' | 'B' | 'C' | 'D' | 'E' | 'F' | 'G';
|
|
92
|
+
/** The certificate the class is from, as typed in the CRM. */
|
|
93
|
+
energy_certificate_version?: string;
|
|
94
|
+
/** Minergie label (Switzerland). */
|
|
95
|
+
minergie?: 'minergie' | 'minergie_p' | 'minergie_eco' | 'minergie_p_eco';
|
|
96
|
+
/** CRM heating key: `central`, `district`, `electric`, `gas`, `heat_pump`, `none`, `oil`, `other`, `pellet`, `solar`, `wood`. */
|
|
97
|
+
heating?: string;
|
|
98
|
+
has_cellar?: boolean;
|
|
99
|
+
indoor_parkings?: number;
|
|
100
|
+
outdoor_parkings?: number;
|
|
101
|
+
/** Monthly charges of the property. */
|
|
102
|
+
charges?: number;
|
|
103
|
+
/** Rentals: monthly extra costs on top of `price`. Absent when the price is hidden. */
|
|
104
|
+
rent_extra?: number;
|
|
105
|
+
/** Price of all the parkings together. Absent when the price is hidden. */
|
|
106
|
+
parking_price?: number;
|
|
107
|
+
/** Price per indoor parking. Absent when the price is hidden. */
|
|
108
|
+
indoor_parking_price?: number;
|
|
109
|
+
/** Price per outdoor parking. Absent when the price is hidden. */
|
|
110
|
+
outdoor_parking_price?: number;
|
|
111
|
+
/** true when the parkings are included in `price`. */
|
|
112
|
+
parking_included?: boolean;
|
|
113
|
+
/** Move-in date, `YYYY-MM-DD`. */
|
|
114
|
+
available_from?: string;
|
|
115
|
+
furnished?: boolean;
|
|
116
|
+
pets_allowed?: boolean;
|
|
117
|
+
video_url?: string;
|
|
118
|
+
virtual_visit_url?: string;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type Listing = PropertyFacts & {
|
|
122
|
+
id: string;
|
|
123
|
+
/** `<title-slug>-<id>`: `listing()` takes it or the id. */
|
|
124
|
+
slug: string;
|
|
125
|
+
title: string;
|
|
126
|
+
/** Sanitized HTML: only p, br, ul, ol, li, strong, em, h3, h4, no attributes. Render with the kit's RichText. */
|
|
127
|
+
description?: string;
|
|
128
|
+
/** The description as plain text, for meta tags and cards. */
|
|
129
|
+
description_text?: string;
|
|
130
|
+
offer_type: OfferType;
|
|
131
|
+
/** CRM property type name, e.g. `apartment`, `house_villa`, `apartment_penthouse`. */
|
|
132
|
+
property_type: string;
|
|
133
|
+
status: ListingStatus;
|
|
134
|
+
/** Absent when hidden: `price_on_request` is then true. */
|
|
135
|
+
price?: number;
|
|
136
|
+
currency: string;
|
|
137
|
+
price_on_request?: boolean;
|
|
138
|
+
rooms?: number;
|
|
139
|
+
bedrooms?: number;
|
|
140
|
+
bathrooms?: number;
|
|
141
|
+
living_surface?: number;
|
|
142
|
+
land_surface?: number;
|
|
143
|
+
address: Address;
|
|
144
|
+
photos: Photo[];
|
|
145
|
+
agent?: Agent;
|
|
146
|
+
/** Feature keys (`elevator`, `balcony`, `parking`): show them translated, never raw. */
|
|
147
|
+
features: string[];
|
|
148
|
+
published_at: string;
|
|
149
|
+
/** The CRM reference agents and clients use (`58W5-DZRY`). */
|
|
150
|
+
reference?: string;
|
|
151
|
+
/** Teams of the listing's broker. */
|
|
152
|
+
team_ids: string[];
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export type UnitStatus = 'available' | 'reserved' | 'sold';
|
|
156
|
+
|
|
157
|
+
/** One unit (lot) of a new-build development. */
|
|
158
|
+
export type Unit = PropertyFacts & {
|
|
159
|
+
id: string;
|
|
160
|
+
/** e.g. "4,5 pièces · Rez-de-jardin". */
|
|
161
|
+
name: string;
|
|
162
|
+
/** The unit number in the development (`A1.2`). */
|
|
163
|
+
reference?: string;
|
|
164
|
+
status: UnitStatus;
|
|
165
|
+
offer_type: OfferType;
|
|
166
|
+
property_type: string;
|
|
167
|
+
rooms?: number;
|
|
168
|
+
bedrooms?: number;
|
|
169
|
+
bathrooms?: number;
|
|
170
|
+
living_surface?: number;
|
|
171
|
+
floor_label?: string;
|
|
172
|
+
/** Only on units for sale whose price is public. */
|
|
173
|
+
price?: number;
|
|
174
|
+
currency: string;
|
|
175
|
+
price_on_request?: boolean;
|
|
176
|
+
/** Units that are live listings: same text and photos as the listing. */
|
|
177
|
+
description?: string;
|
|
178
|
+
description_text?: string;
|
|
179
|
+
photos?: Photo[];
|
|
180
|
+
/** The unit's outline on the development's `site_plan`: SVG polygon points in the image's own pixels. */
|
|
181
|
+
site_plan_points?: string;
|
|
182
|
+
/** Set when the unit is also a listing live on one of the client's portals: `listing(listing_slug)`. */
|
|
183
|
+
listing_slug?: string;
|
|
184
|
+
features: string[];
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
type Range = { min: number; max: number };
|
|
188
|
+
|
|
189
|
+
/** A company of a development: its developer, architect or general contractor. */
|
|
190
|
+
export type Company = { name: string; website?: string; logo?: Photo };
|
|
191
|
+
|
|
192
|
+
export type Development = {
|
|
193
|
+
id: string;
|
|
194
|
+
slug: string;
|
|
195
|
+
title: string;
|
|
196
|
+
description?: string;
|
|
197
|
+
description_text?: string;
|
|
198
|
+
/** CRM development status: planning, construction, marketing, delivered, ... */
|
|
199
|
+
status?: string;
|
|
200
|
+
delivery_date?: string;
|
|
201
|
+
address: Address;
|
|
202
|
+
photos: Photo[];
|
|
203
|
+
site_plan?: Photo;
|
|
204
|
+
developer?: Company;
|
|
205
|
+
architect?: Company;
|
|
206
|
+
general_contractor?: Company;
|
|
207
|
+
stats: {
|
|
208
|
+
units: number;
|
|
209
|
+
available: number;
|
|
210
|
+
reserved: number;
|
|
211
|
+
sold: number;
|
|
212
|
+
price?: Range;
|
|
213
|
+
rooms?: Range;
|
|
214
|
+
living_surface?: Range;
|
|
215
|
+
};
|
|
216
|
+
currency: string;
|
|
217
|
+
agents: Agent[];
|
|
218
|
+
/** Teams of the development's broker. */
|
|
219
|
+
team_ids: string[];
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
export type DevelopmentWithUnits = Development & { units: Unit[] };
|
|
223
|
+
|
|
224
|
+
/** The site's portals by name: `main` is the default and the enquiry portal. */
|
|
225
|
+
export type Portals = { main: string } & Record<string, string>;
|
|
226
|
+
|
|
227
|
+
export type ListingsQuery = {
|
|
228
|
+
/** A name from the client's `portals`. Default `main`. */
|
|
229
|
+
portal?: string;
|
|
230
|
+
team_id?: string;
|
|
231
|
+
offer_type?: OfferType;
|
|
232
|
+
/** Exact type name, or a prefix: `apartment` matches `apartment_furnished_flat`. */
|
|
233
|
+
property_type?: string;
|
|
234
|
+
status?: ListingStatus;
|
|
235
|
+
/** Case- and accent-insensitive: `geneve` finds Genève. */
|
|
236
|
+
city?: string;
|
|
237
|
+
min_price?: number;
|
|
238
|
+
max_price?: number;
|
|
239
|
+
min_rooms?: number;
|
|
240
|
+
/** The page language (`de`, `fr-CH`): texts in it when the CRM has them (`text_locale`), else the original. */
|
|
241
|
+
locale?: string;
|
|
242
|
+
/** 1..200, default 24. */
|
|
243
|
+
limit?: number;
|
|
244
|
+
offset?: number;
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
/** Which language to read texts in. */
|
|
248
|
+
export type TextQuery = {
|
|
249
|
+
/** The page language (`de`, `fr-CH`): texts in it when the CRM has them (`text_locale`), else the original. */
|
|
250
|
+
locale?: string;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
export type Pagination = {
|
|
254
|
+
limit: number;
|
|
255
|
+
offset: number;
|
|
256
|
+
total: number;
|
|
257
|
+
/** true when `total` stopped counting at its cap. */
|
|
258
|
+
total_is_capped: boolean;
|
|
259
|
+
has_more: boolean;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
/** One page of a list, as rest-api answers it. */
|
|
263
|
+
export type Page<T> = { data: T[]; pagination: Pagination };
|
|
264
|
+
|
|
265
|
+
export type ClientOptions = {
|
|
266
|
+
/** Without: the site's own portal (the key's), for reads and enquiries. */
|
|
267
|
+
portals?: Portals;
|
|
268
|
+
/** Tests only: else process.env.SITES_API_URL. */
|
|
269
|
+
url?: string;
|
|
270
|
+
/** Tests only: else process.env.SITES_API_KEY. */
|
|
271
|
+
key?: string;
|
|
272
|
+
/** Tests only: else globalThis.fetch. */
|
|
273
|
+
fetch?: typeof fetch;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export type SitesClient = {
|
|
277
|
+
organisation(): Promise<Organisation>;
|
|
278
|
+
/** Primary team first. */
|
|
279
|
+
teams(): Promise<Team[]>;
|
|
280
|
+
/** The whole organisation without team_id. */
|
|
281
|
+
agents(q?: { team_id?: string }): Promise<Agent[]>;
|
|
282
|
+
listings(q?: ListingsQuery): Promise<Page<Listing>>;
|
|
283
|
+
/** Every page of `listings(q)`. */
|
|
284
|
+
allListings(q?: Omit<ListingsQuery, 'limit' | 'offset'>): Promise<Listing[]>;
|
|
285
|
+
/** Live on any of the site's portals; null when not found. */
|
|
286
|
+
listing(slugOrId: string, q?: TextQuery): Promise<Listing | null>;
|
|
287
|
+
developments(q?: { team_id?: string } & TextQuery): Promise<Development[]>;
|
|
288
|
+
/** The units' texts follow `locale`; the CRM keeps a development's own texts in one language. */
|
|
289
|
+
development(slugOrId: string, q?: TextQuery): Promise<DevelopmentWithUnits | null>;
|
|
290
|
+
/**
|
|
291
|
+
* The public Turnstile key the enquiry form renders, kept 5 min. null: no
|
|
292
|
+
* widget (verification off, or the lookup failed). Fixture:
|
|
293
|
+
* FIXTURE_TURNSTILE_SITE_KEY, a stand-in that never loads Turnstile.
|
|
294
|
+
*/
|
|
295
|
+
turnstileSiteKey(): Promise<string | null>;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/** A fixture record's texts in other languages, by language (`de`, `fr`, ...). */
|
|
299
|
+
export type FixtureTranslations = {
|
|
300
|
+
translations?: Record<string, { title?: string; description?: string; description_text?: string }>;
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* A site's data without the API (`createFixtureClient`): the same records,
|
|
305
|
+
* as the API answers them. Sites may add their own keys. A listing or unit
|
|
306
|
+
* may carry `translations`: with `locale`, the fixture client serves them as
|
|
307
|
+
* the API serves the CRM's (`text_locale` when every text is translated),
|
|
308
|
+
* and never passes `translations` on.
|
|
309
|
+
*/
|
|
310
|
+
export type Fixture = {
|
|
311
|
+
organisation: Organisation;
|
|
312
|
+
/** Primary team first. */
|
|
313
|
+
teams: Team[];
|
|
314
|
+
agents: Agent[];
|
|
315
|
+
listings: Array<Listing & FixtureTranslations>;
|
|
316
|
+
developments?: Array<Omit<DevelopmentWithUnits, 'units'> & { units: Array<Unit & FixtureTranslations> }>;
|
|
317
|
+
/** The portals a live client of this site uses. */
|
|
318
|
+
portals?: Portals;
|
|
319
|
+
/** What turnstileSiteKey() answers. Default FIXTURE_TURNSTILE_SITE_KEY; null: no widget. */
|
|
320
|
+
turnstile_site_key?: string | null;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Every failure, as `err.code`. Not found is never an error (`null`).
|
|
325
|
+
* - `invalid` 400: a field is wrong, `fields` says which.
|
|
326
|
+
* - `invalid_portal` 400: a portal is not an api portal of the organisation.
|
|
327
|
+
* - `invalid_team` 400: team_id is not a team of the organisation.
|
|
328
|
+
* - `rejected` 400: the CRM refused the enquiry.
|
|
329
|
+
* - `site_key` 401, 403: the site's key was revoked or rotated, or may not read this.
|
|
330
|
+
* - `verification_required` 403: no valid Turnstile token.
|
|
331
|
+
* - `rate_limited` 429: too many enquiries.
|
|
332
|
+
* - `unavailable` any other status, a timeout, or no data access configured.
|
|
333
|
+
*/
|
|
334
|
+
export type ErrorCode =
|
|
335
|
+
| 'invalid'
|
|
336
|
+
| 'invalid_portal'
|
|
337
|
+
| 'invalid_team'
|
|
338
|
+
| 'rejected'
|
|
339
|
+
| 'site_key'
|
|
340
|
+
| 'verification_required'
|
|
341
|
+
| 'rate_limited'
|
|
342
|
+
| 'unavailable';
|