abmp-npm 2.0.76 → 2.0.77
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/.claude/skills/wix-data-query/SKILL.md +167 -0
- package/.claude/skills/wix-data-query/references/backup-archive.md +136 -0
- package/.claude/skills/wix-data-query/references/members-data-latest.md +125 -0
- package/.claude/skills/wix-data-query/references/recipes.md +264 -0
- package/backend/__tests__/map-link.test.js +115 -0
- package/backend/__tests__/url-validation.test.js +69 -0
- package/package.json +1 -1
- package/pages/Home.js +27 -55
- package/pages/index.js +0 -1
- package/public/Utils/personalDetailsUtils.js +5 -1
- package/public/Utils/sharedUtils.js +32 -0
- package/public/consts.js +0 -1
- package/pages/contactForLocation.js +0 -28
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: wix-data-query
|
|
3
|
+
description: Query the ABMP/ASCP/AHP Wix CMS collections (MembersDataLatest, SiteConfigs, etc.) via the Wix Data REST API to investigate member data issues. Use when a bug report, Monday ticket, or support escalation references a specific member ID, profile slug, or email — e.g. "services don't appear on the website", "book now link missing", "member still showing after they dropped", "expired license rendering", "address/lat-long is wrong", "upgraded membership didn't sync". Also use when you need a collection's real field names, types, or query operators before writing backend code.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Wix Data queries for member data investigations
|
|
7
|
+
|
|
8
|
+
Read the live CMS record before theorising. Most "the website is wrong" tickets are
|
|
9
|
+
answered in one query: the site renders what is in `MembersDataLatest`, so if the field
|
|
10
|
+
is wrong there, the bug is in the sync (`backend/daily-pull/`), not in the UI.
|
|
11
|
+
|
|
12
|
+
## 1. Pick the site
|
|
13
|
+
|
|
14
|
+
Every association is a **separate Wix site with its own copy of the collections**. A member
|
|
15
|
+
who exists on ABMP may not exist on ASCP. Always confirm which site the ticket is about —
|
|
16
|
+
the profile URL tells you (`abmpmembers.com` → ABMP, `ascpskincare.com` → ASCP).
|
|
17
|
+
|
|
18
|
+
| Site | siteId | Env |
|
|
19
|
+
| ---------------------- | -------------------------------------- | ---- |
|
|
20
|
+
| ABMP Members Directory | `384d680a-2870-4086-bda0-9894ce4503b8` | prod |
|
|
21
|
+
| ASCP Members Directory | `1cb02bba-3a36-45e0-bdb4-1a1a2cfe2fdc` | prod |
|
|
22
|
+
| AHP Members Directory | `5553798e-c71e-4a58-9b9e-515803823429` | prod |
|
|
23
|
+
| Test ABMP Members | `cd9fca47-63d3-4538-b26c-1f91ad0a9420` | test |
|
|
24
|
+
| Test ASCP Members | `8c031731-3f58-4d5f-b7dc-6ccabd1b5722` | test |
|
|
25
|
+
| Test AHP Members | `4535a35f-439d-4558-8e68-9000258e2a2a` | test |
|
|
26
|
+
|
|
27
|
+
Collection IDs are in [`public/consts.js`](../../../public/consts.js) under `COLLECTIONS`.
|
|
28
|
+
The main one is `MembersDataLatest`.
|
|
29
|
+
|
|
30
|
+
## 2. Pick the auth path
|
|
31
|
+
|
|
32
|
+
**Path A — Wix MCP (preferred when the `CallWixSiteAPI` tool is available).** No secrets to
|
|
33
|
+
handle; auth is already managed. This is the path used to validate every recipe in this skill.
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
CallWixSiteAPI(
|
|
37
|
+
siteId: "384d680a-2870-4086-bda0-9894ce4503b8",
|
|
38
|
+
url: "https://www.wixapis.com/wix-data/v2/items/query",
|
|
39
|
+
method: "POST",
|
|
40
|
+
sourceDocUrl: "https://dev.wix.com/docs/api-reference/business-solutions/cms/data-items/query-data-items",
|
|
41
|
+
body: { ...see recipes... }
|
|
42
|
+
)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Path B — raw REST**, for scripts, CI, or any session without the MCP. Requires an admin API
|
|
46
|
+
key from the [API Keys Manager](https://manage.wix.com/account/api-keys). Two headers, per the
|
|
47
|
+
[auth docs](https://dev.wix.com/docs/api-reference/articles/authentication/api-keys/make-api-calls-with-an-api-key):
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
curl -s -X POST 'https://www.wixapis.com/wix-data/v2/items/query' \
|
|
51
|
+
-H "Authorization: $WIX_API_KEY" \
|
|
52
|
+
-H "wix-site-id: 384d680a-2870-4086-bda0-9894ce4503b8" \
|
|
53
|
+
-H 'Content-Type: application/json' \
|
|
54
|
+
-d '{"dataCollectionId":"MembersDataLatest","query":{"filter":{"memberId":731898},"paging":{"limit":1}}}'
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Read the key from the environment (`$WIX_API_KEY`). Never paste a key into a file, a commit,
|
|
58
|
+
a Monday comment, or a chat message. `MembersDataLatest` is `read: ADMIN` /
|
|
59
|
+
`itemRead: PRIVILEGED`, so a visitor token will return nothing — this is expected, not a bug.
|
|
60
|
+
|
|
61
|
+
## 3. Query
|
|
62
|
+
|
|
63
|
+
Full cookbook with copy-paste bodies: [references/recipes.md](references/recipes.md).
|
|
64
|
+
The single most common one — look a member up by profile slug:
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"dataCollectionId": "MembersDataLatest",
|
|
69
|
+
"query": { "filter": { "url": "karriknowles" }, "paging": { "limit": 1 } }
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
By numeric PAC member ID (note: **number, not string**):
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"dataCollectionId": "MembersDataLatest",
|
|
78
|
+
"query": { "filter": { "memberId": 731898 }, "paging": { "limit": 1 } }
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## 4. Read the result against the field reference
|
|
83
|
+
|
|
84
|
+
[references/members-data-latest.md](references/members-data-latest.md) lists every field, its
|
|
85
|
+
type, and — importantly — the **traps**. The ones that cause wrong conclusions:
|
|
86
|
+
|
|
87
|
+
- **`firstName`, `lastName`, `phone`, `toShowPhone` are encrypted.** They support only
|
|
88
|
+
`EQ`, `NE`, `HAS_SOME`, `EXISTS`. A `CONTAINS`/`STARTS_WITH` name search **fails** — it does
|
|
89
|
+
not silently return nothing, it errors. Search by `fullName` (not encrypted) instead.
|
|
90
|
+
- **Duplicate legacy field pairs exist**: `showAbmp` _and_ `showABMP`, `apiBookingUrl` _and_
|
|
91
|
+
`APIBookingUrl`. Check both before concluding a value is missing.
|
|
92
|
+
- **A `show*` boolean gates almost every "X doesn't appear on the site" ticket.** The data can
|
|
93
|
+
be perfectly correct and still not render because `showBookingUrl` / `showWebsite` /
|
|
94
|
+
`showContactForm` / `showName` is `false`. Check the flag before blaming the sync.
|
|
95
|
+
- **`isVisible` and `action`** control directory presence. `action: "drop"` sets
|
|
96
|
+
`isVisible: false` (see `backend/daily-pull/process-member-methods.js`). A member who should
|
|
97
|
+
have been dropped but is still listed will show `action` other than `drop`, or
|
|
98
|
+
`isVisible: true` — that points at the PAC API payload, not at Wix.
|
|
99
|
+
- **Two date formats, and mixing them fails silently.** `_createdDate` / `_updatedDate` are
|
|
100
|
+
real `DATETIME` fields using `{"$date":"...Z"}`. But dates inside the `memberships` and
|
|
101
|
+
`licenses` arrays are **plain ISO strings** (`"2027-06-12T00:00:00"` — no `Z`, no ms) and
|
|
102
|
+
must be compared as strings. Verified on ABMP prod: filtering
|
|
103
|
+
`memberships.expiration` with `$date` returns **0**; the same filter as a string returns
|
|
104
|
+
**1008**. A zero here is far more often a wrong filter than a clean bill of health.
|
|
105
|
+
- **Totals:** `returnTotalCount` does not return a `total` on this collection. Use
|
|
106
|
+
`POST https://www.wixapis.com/wix-data/v2/items/count` (body: `dataCollectionId` +
|
|
107
|
+
top-level `filter`, no `query` wrapper) → `{"totalCount": N}`.
|
|
108
|
+
|
|
109
|
+
- **`$eq` is case-sensitive; `$contains` is case-insensitive.** Verified on live ABMP: filtering
|
|
110
|
+
`url` with `"alisadanaeknowles"` matches the stored `"AlisaDanaeKnowles"` under `$contains`
|
|
111
|
+
and returns **nothing** under `$eq`. Stored slugs really are mixed case, so an exact-match
|
|
112
|
+
lookup on a user-supplied slug will silently miss. This is why `getMemberBySlug` originally
|
|
113
|
+
reached for full-text search.
|
|
114
|
+
|
|
115
|
+
## 5. The historical PAC feed archive
|
|
116
|
+
|
|
117
|
+
The live CMS answers _"what is true now"_. It cannot answer _"what did PAC actually send"_ or
|
|
118
|
+
_"what did this member originally ask for"_ — and those are usually the questions that settle a
|
|
119
|
+
dispute. For that, use the backup archive: [references/backup-archive.md](references/backup-archive.md).
|
|
120
|
+
|
|
121
|
+
## 6. Writing data
|
|
122
|
+
|
|
123
|
+
Read [§7 Rules](#7-rules) first. Writes need explicit approval, every time.
|
|
124
|
+
|
|
125
|
+
**Prefer `PATCH` over `PUT`.** `PUT` (Update Data Item) replaces the entire payload — miss a
|
|
126
|
+
field and you wipe it. `PATCH` changes only what you name:
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
PATCH https://www.wixapis.com/wix-data/v2/items/<itemId>
|
|
130
|
+
{
|
|
131
|
+
"dataCollectionId": "MembersDataLatest",
|
|
132
|
+
"patch": {
|
|
133
|
+
"dataItemId": "<itemId>",
|
|
134
|
+
"fieldModifications": [
|
|
135
|
+
{ "fieldPath": "optOut", "action": "SET_FIELD", "setFieldOptions": { "value": true } }
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Hiding a member is better than deleting one.** `optOut: true` is reversible, keeps the record,
|
|
142
|
+
and **survives the nightly sync** — `optOut` is written only in `getNewMemberOnlyFields`, which
|
|
143
|
+
returns `{}` for members that already exist, and it is absent from the always-update list in
|
|
144
|
+
`backend/daily-pull/process-member-methods.js`. Deleting is irreversible, and if the member is
|
|
145
|
+
still in PAC's feed the next sync simply recreates them as new.
|
|
146
|
+
|
|
147
|
+
Deleting, when that is genuinely what was asked for:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
DELETE https://www.wixapis.com/wix-data/v2/items/<itemId>?dataCollectionId=MembersDataLatest
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Always verify afterwards with `/items/count` on the member id and report the result.
|
|
154
|
+
|
|
155
|
+
## 7. Rules
|
|
156
|
+
|
|
157
|
+
- **Read-only by default.** Query, count, distinct, aggregate, get-schema are all fine to run
|
|
158
|
+
unprompted during an investigation.
|
|
159
|
+
- **Never write to a production collection without explicit approval in this conversation.**
|
|
160
|
+
Inserts, updates, patches, and `TRUNCATE` change live member-facing data. State exactly what
|
|
161
|
+
you intend to change and on which site, and wait for a yes. When a fix needs testing, use the
|
|
162
|
+
Test site IDs above.
|
|
163
|
+
- **Don't paste member PII into external systems.** These records contain real names, emails,
|
|
164
|
+
phone numbers, and home addresses. Quote the minimum needed — a member ID and the one wrong
|
|
165
|
+
field — when writing a Monday comment or a commit message.
|
|
166
|
+
- Ground endpoints in docs, not memory. If you need an endpoint this skill doesn't cover, find
|
|
167
|
+
it with `SearchWixRESTDocumentation` first.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# The PAC feed archive (`abmp-backup-api`)
|
|
2
|
+
|
|
3
|
+
A local archive of **raw PAC API responses**, in the repo behind `BACKUP_API_URL`
|
|
4
|
+
(`backend/consts.js`). On this machine:
|
|
5
|
+
|
|
6
|
+
```
|
|
7
|
+
/Users/Matheusa/Documents/GitHub/abmp-backup-api/backups/
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
It is the only record of **what PAC actually sent** and of members' **pre-migration display
|
|
11
|
+
preferences**. Neither exists anywhere in the live CMS.
|
|
12
|
+
|
|
13
|
+
## Layout
|
|
14
|
+
|
|
15
|
+
Bucketed by the PAC `action` value; each file is a verbatim API page.
|
|
16
|
+
|
|
17
|
+
| Folder | Files | Records |
|
|
18
|
+
| ------------------ | --------------- | ---------------------- |
|
|
19
|
+
| `new/` | `page1.json` | 233 |
|
|
20
|
+
| `new_Nov18th/` | `page1..5.json` | 11,960 (full snapshot) |
|
|
21
|
+
| `update/` | `page1.json` | 954 |
|
|
22
|
+
| `drop/` | `page1.json` | **416** |
|
|
23
|
+
| `failed requests/` | — | empty |
|
|
24
|
+
|
|
25
|
+
13,563 records, 13,478 unique members, all four associations
|
|
26
|
+
(ABMP 5,586 / ASCP 6,656 / AHP 1,526 / ANP 176 memberships). Captured **Nov–Dec 2025**.
|
|
27
|
+
|
|
28
|
+
Shape matches the live PAC API exactly:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"total_results": 954,
|
|
33
|
+
"page_results": 954,
|
|
34
|
+
"total_pages": 1,
|
|
35
|
+
"results": [
|
|
36
|
+
{
|
|
37
|
+
"memberid": 1493418,
|
|
38
|
+
"firstname": "Andrea",
|
|
39
|
+
"lastname": "Williams",
|
|
40
|
+
"email": "…",
|
|
41
|
+
"phones": [],
|
|
42
|
+
"url": "…",
|
|
43
|
+
"action": "update",
|
|
44
|
+
"licenses": [],
|
|
45
|
+
"addresses": [],
|
|
46
|
+
"memberships": [],
|
|
47
|
+
"migrationData": {}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## `migrationData` — the part that matters
|
|
54
|
+
|
|
55
|
+
Present on **every** record and absent from the live CMS. These are the members' legacy display
|
|
56
|
+
preferences, carried over at migration:
|
|
57
|
+
|
|
58
|
+
| Key | Answers the ticket type |
|
|
59
|
+
| ------------------------------------------------ | --------------------------------------------------------- |
|
|
60
|
+
| `opted_out` | _"I asked not to be listed"_ |
|
|
61
|
+
| `interests` | _"my services don't appear"_ (becomes `areasOfPractices`) |
|
|
62
|
+
| `website`, `logo_url` | _"my website/logo used to show"_ |
|
|
63
|
+
| `schedule_code` | _"my booking link is missing"_ |
|
|
64
|
+
| `show_phone`, `addressinfo`, `show_member_since` | _"my details display wrong"_ |
|
|
65
|
+
| `detailtext` | original profile blurb |
|
|
66
|
+
|
|
67
|
+
## ⚠️ Limits — read before concluding anything
|
|
68
|
+
|
|
69
|
+
1. **It is a single Nov–Dec 2025 snapshot, not a continuous log.** There is no record of any
|
|
70
|
+
later sync.
|
|
71
|
+
2. **It does not contain every member.** 13,478 records against ~9,400 on the AHP site alone.
|
|
72
|
+
3. Therefore **absence proves nothing.** "Not in the backup" does **not** mean "PAC never sent
|
|
73
|
+
it" — the single most tempting wrong inference here.
|
|
74
|
+
|
|
75
|
+
Presence, by contrast, is solid evidence: if a member is in `drop/`, PAC really did send a drop
|
|
76
|
+
for them.
|
|
77
|
+
|
|
78
|
+
## Recipe: compare legacy preference against live state
|
|
79
|
+
|
|
80
|
+
Index the archive by `memberid`, then batch-query the CMS. This is how the opted-out check below
|
|
81
|
+
was done.
|
|
82
|
+
|
|
83
|
+
```js
|
|
84
|
+
const fs = require('fs');
|
|
85
|
+
const dir = '/Users/Matheusa/Documents/GitHub/abmp-backup-api/backups';
|
|
86
|
+
const idx = new Map();
|
|
87
|
+
for (const d of fs.readdirSync(dir)) {
|
|
88
|
+
const p = `${dir}/${d}`;
|
|
89
|
+
if (!fs.statSync(p).isDirectory()) continue;
|
|
90
|
+
for (const f of fs.readdirSync(p)) {
|
|
91
|
+
if (!f.endsWith('.json')) continue;
|
|
92
|
+
for (const r of JSON.parse(fs.readFileSync(`${p}/${f}`, 'utf8')).results || []) {
|
|
93
|
+
idx.set(r.memberid, { bucket: d, action: r.action, mig: r.migrationData });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Then, against the live site — `$hasSome` acts as "in" for a NUMBER field:
|
|
100
|
+
|
|
101
|
+
```json
|
|
102
|
+
{
|
|
103
|
+
"dataCollectionId": "MembersDataLatest",
|
|
104
|
+
"query": {
|
|
105
|
+
"filter": { "memberId": { "$hasSome": [804491, 817198, 137584] } },
|
|
106
|
+
"fields": ["memberId", "fullName", "optOut", "isVisible", "action"],
|
|
107
|
+
"paging": { "limit": 40 }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Finding on record (2026-08-04)
|
|
113
|
+
|
|
114
|
+
40 members had `migrationData.opted_out === true`. 32 are still active (`new`/`update`); of those
|
|
115
|
+
**29 correctly carry `optOut: true` today**, but **3 do not and are publicly visible on ABMP**:
|
|
116
|
+
|
|
117
|
+
| memberId | Name |
|
|
118
|
+
| -------- | -------------------------- |
|
|
119
|
+
| 1091946 | Jessica Solorzano-Peterson |
|
|
120
|
+
| 881021 | Arlette Underwood |
|
|
121
|
+
| 765272 | Summer Fairbanks |
|
|
122
|
+
|
|
123
|
+
**Not confirmed as a bug.** `optOut` is member-editable, so these three may have deliberately
|
|
124
|
+
opted back in since migration. That cannot be distinguished remotely — the nightly sync touches
|
|
125
|
+
`_updatedDate` on every record, so it is not a usable signal. Raise with PAC rather than assume,
|
|
126
|
+
and do not change a member's `optOut` on the strength of this alone.
|
|
127
|
+
|
|
128
|
+
## Other uses
|
|
129
|
+
|
|
130
|
+
- **"Was this member ever actually dropped?"** — the `drop/` bucket is ground truth, and is the
|
|
131
|
+
unanswerable question behind the _multies not renewing_ ticket.
|
|
132
|
+
- **Provenance** — telling a PAC-sourced record from a Wix-created test account. PAC-sourced
|
|
133
|
+
records have a real sequential `memberId` (~1.2M–1.8M), a `pageNumber` on the CMS side (written
|
|
134
|
+
only while processing a page of the PAC API response), and ISO `membersince`/`expiration`.
|
|
135
|
+
Wix-made test accounts use ids like `11111111`, throwaway email domains, and hand-typed date
|
|
136
|
+
strings such as `"November 2012"`.
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# `MembersDataLatest` field reference
|
|
2
|
+
|
|
3
|
+
Captured from the live **ABMP Members Directory** collection schema
|
|
4
|
+
(`GET https://www.wixapis.com/wix-data/v2/collections/MembersDataLatest`) on 2026-08-04,
|
|
5
|
+
collection revision `102`. Re-run that call to refresh — the schema is the source of truth,
|
|
6
|
+
this file is a convenience copy.
|
|
7
|
+
|
|
8
|
+
Permissions: `read/insert/update/remove: ADMIN`, `dataPermissions.itemRead: PRIVILEGED`.
|
|
9
|
+
Paging modes: `OFFSET`, `CURSOR`. Supports `COUNT`, `DISTINCT`, `AGGREGATE`.
|
|
10
|
+
|
|
11
|
+
## Identity
|
|
12
|
+
|
|
13
|
+
| Field | Type | Notes |
|
|
14
|
+
| ------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
15
|
+
| `_id` | TEXT | Wix item ID (system) |
|
|
16
|
+
| `memberId` | **NUMBER** | PAC member ID. Filter with a number, not a string. |
|
|
17
|
+
| `url` | TEXT | Profile slug, e.g. `karriknowles` → `/profile/karriknowles`. Uniqueness enforced by `ensureUniqueUrl` in `backend/daily-pull/process-member-methods.js`. |
|
|
18
|
+
| `generatedUrl` | BOOLEAN | True when the slug was auto-generated rather than PAC-supplied |
|
|
19
|
+
| `wixMemberId` | TEXT | Wix Members app ID |
|
|
20
|
+
| `wixContactId` | TEXT | Wix CRM contact ID |
|
|
21
|
+
| `contactId` | TEXT | Legacy contact ID field |
|
|
22
|
+
| `_owner` | TEXT | System |
|
|
23
|
+
| `_createdDate` / `_updatedDate` | DATETIME | `{"$date":"...Z"}` shape |
|
|
24
|
+
| `pageNumber` | NUMBER | Which PAC API page last wrote this record — useful for tracing a sync run |
|
|
25
|
+
|
|
26
|
+
## Name and contact
|
|
27
|
+
|
|
28
|
+
| Field | Type | Notes |
|
|
29
|
+
| ----------------------------------- | -------------- | ----------------------------------------------------------------------------------- |
|
|
30
|
+
| `firstName` | TEXT | 🔒 **encrypted** — only `EQ`, `NE`, `HAS_SOME`, `EXISTS` |
|
|
31
|
+
| `lastName` | TEXT | 🔒 **encrypted** — same restriction |
|
|
32
|
+
| `fullName` | TEXT | Not encrypted → **use this for name searches** (`CONTAINS`, `STARTS_WITH` all work) |
|
|
33
|
+
| `businessName` / `showBusinessName` | TEXT / BOOLEAN | |
|
|
34
|
+
| `email` | TEXT | Login email |
|
|
35
|
+
| `contactFormEmail` | TEXT | Where contact-form mail goes; diverges from `email` by design |
|
|
36
|
+
| `phone` | TEXT | 🔒 **encrypted** |
|
|
37
|
+
| `toShowPhone` | TEXT | 🔒 **encrypted** — the phone actually rendered |
|
|
38
|
+
| `phones` | ARRAY | Full list from PAC |
|
|
39
|
+
|
|
40
|
+
## Membership and licensing
|
|
41
|
+
|
|
42
|
+
| Field | Type | Notes |
|
|
43
|
+
| --------------- | ------- | -------------------------------------------------------------------------------------------- |
|
|
44
|
+
| `action` | TEXT | From the PAC API: `new` / `update` / `drop` / `none`. Drives `isVisible`. |
|
|
45
|
+
| `isVisible` | BOOLEAN | `action !== 'drop'`. Controls directory listing. |
|
|
46
|
+
| `optOut` | BOOLEAN | Member-chosen suppression, independent of `action` |
|
|
47
|
+
| `memberships` | ARRAY | `{association, membertype, expiration, membersince}` |
|
|
48
|
+
| `licenses` | ARRAY | `{association, state, license, exempt}` — filtered per-site by `filterLicensesByAssociation` |
|
|
49
|
+
| `showLicenseNo` | BOOLEAN | |
|
|
50
|
+
|
|
51
|
+
## Location
|
|
52
|
+
|
|
53
|
+
| Field | Type | Notes |
|
|
54
|
+
| ---------------------- | ------ | ---------------------------------------------------------------------------------- |
|
|
55
|
+
| `addresses` | ARRAY | `{key, line1, line2, city, state, postalcode, latitude, longitude, addressStatus}` |
|
|
56
|
+
| `addressDisplayOption` | ARRAY | `[{key, isMain}]` — which address is primary |
|
|
57
|
+
| `addressInfo` | OBJECT | Map of address `key` → display mode |
|
|
58
|
+
| `locHash` | ARRAY | Geohash (precision 3, see `GEO_HASH_PRECISION`) used for proximity search |
|
|
59
|
+
|
|
60
|
+
`addressStatus` values come from `ADDRESS_STATUS_TYPES` in `public/consts.js`:
|
|
61
|
+
`full_address`, `state_city_zip`, `dont_show`.
|
|
62
|
+
|
|
63
|
+
## Profile content
|
|
64
|
+
|
|
65
|
+
| Field | Type | Notes |
|
|
66
|
+
| ------------------ | ------------- | ----------------------------------------------------------------------------------------------------- |
|
|
67
|
+
| `areasOfPractices` | ARRAY | **This is the "services" list** members complain about. There is no field literally named `services`. |
|
|
68
|
+
| `aboutService` | RICH_TEXT | HTML string |
|
|
69
|
+
| `testimonial` | ARRAY | Free-text testimonials |
|
|
70
|
+
| `gallery` | MEDIA_GALLERY | |
|
|
71
|
+
| `bannerImages` | ARRAY | |
|
|
72
|
+
| `profileImage` | IMAGE | `wix:image://` URI |
|
|
73
|
+
| `logoImage` | URL | |
|
|
74
|
+
| `title` | TEXT | Default CMS field, generally unused |
|
|
75
|
+
|
|
76
|
+
## Display flags — check these first on "X doesn't appear" tickets
|
|
77
|
+
|
|
78
|
+
| Field | Type |
|
|
79
|
+
| ----------------------------- | ------- | --------------------------------- |
|
|
80
|
+
| `showName` | BOOLEAN |
|
|
81
|
+
| `showPhone` | BOOLEAN |
|
|
82
|
+
| `showWebsite` | BOOLEAN |
|
|
83
|
+
| `showWixUrl` | BOOLEAN |
|
|
84
|
+
| `showContactForm` | BOOLEAN |
|
|
85
|
+
| `showBookingUrl` | BOOLEAN |
|
|
86
|
+
| `showBusinessName` | BOOLEAN |
|
|
87
|
+
| `showLicenseNo` | BOOLEAN |
|
|
88
|
+
| `showAbmp` **and** `showABMP` | BOOLEAN | ⚠️ two separate fields both exist |
|
|
89
|
+
|
|
90
|
+
## Links
|
|
91
|
+
|
|
92
|
+
| Field | Type | Notes |
|
|
93
|
+
| --------------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
94
|
+
| `website` | URL | Member's own site |
|
|
95
|
+
| `bookingUrl` | URL | Member-entered booking link; rendered only when `showBookingUrl` is `true` |
|
|
96
|
+
| `apiBookingUrl` **and** `APIBookingUrl` | TEXT | ⚠️ two separate fields. Can contain a raw HTML embed blob (e.g. a Genbook `<script>` badge), not just a URL — don't assume it parses as a URL. |
|
|
97
|
+
|
|
98
|
+
## Known traps
|
|
99
|
+
|
|
100
|
+
1. **Encrypted fields reject substring operators.** `CONTAINS` on `firstName` errors out. Use
|
|
101
|
+
`fullName`.
|
|
102
|
+
2. **Duplicate-case field pairs** (`showAbmp`/`showABMP`, `apiBookingUrl`/`APIBookingUrl`) are
|
|
103
|
+
real and both queryable. A value "missing" from one may be present in the other.
|
|
104
|
+
3. **`memberId` is a NUMBER.** `{"memberId": "731898"}` matches nothing and returns an empty
|
|
105
|
+
list rather than an error — the most common false "member not found".
|
|
106
|
+
4. **Per-site collections.** Absence on one site is not absence everywhere.
|
|
107
|
+
5. **Eventually consistent.** A write may not be visible to the next immediate query.
|
|
108
|
+
6. **Two different date formats.** Only true `DATETIME` fields (`_createdDate`,
|
|
109
|
+
`_updatedDate`) use the `{"$date":"...Z"}` form. Dates _inside_ the `memberships` and
|
|
110
|
+
`licenses` arrays — notably `memberships.expiration` and `memberships.membersince` — are
|
|
111
|
+
**plain ISO strings without a `Z` or milliseconds** (`"2027-06-12T00:00:00"`) and must be
|
|
112
|
+
filtered as strings. Using `$date` against them silently returns zero rows rather than
|
|
113
|
+
erroring, which reads as "no affected members" when there may be thousands.
|
|
114
|
+
7. **`returnTotalCount` yields no `total`** on this collection — use
|
|
115
|
+
`POST /wix-data/v2/items/count` instead.
|
|
116
|
+
8. **`{"$ne": ""}` also matches rows where the field is absent.** Verified 2026-08-04: filtering
|
|
117
|
+
`{"website": {"$ne": ""}}` returned 300 rows whose projected `website` and `bookingUrl` came
|
|
118
|
+
back empty — the field simply wasn't set on them. So `$ne ""` is _not_ "has a value", and any
|
|
119
|
+
count built on it is inflated. To mean "actually has a value", pair it with
|
|
120
|
+
`{"$exists": true}` or filter/verify client-side after projecting the field.
|
|
121
|
+
|
|
122
|
+
## No regex filtering
|
|
123
|
+
|
|
124
|
+
Wix Data has no regex operator, so you cannot ask the API questions like "domains containing a
|
|
125
|
+
digit". Project the field, page through, and evaluate in JS.
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# Query cookbook
|
|
2
|
+
|
|
3
|
+
All bodies below are the JSON body for
|
|
4
|
+
`POST https://www.wixapis.com/wix-data/v2/items/query`
|
|
5
|
+
([docs](https://dev.wix.com/docs/api-reference/business-solutions/cms/data-items/query-data-items)).
|
|
6
|
+
|
|
7
|
+
Send them via `CallWixSiteAPI(siteId, url, method: "POST", body)` or via curl with
|
|
8
|
+
`Authorization: $WIX_API_KEY` + `wix-site-id: <siteId>`.
|
|
9
|
+
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Look up one member
|
|
13
|
+
|
|
14
|
+
By profile slug (from the URL in the ticket):
|
|
15
|
+
|
|
16
|
+
```json
|
|
17
|
+
{
|
|
18
|
+
"dataCollectionId": "MembersDataLatest",
|
|
19
|
+
"query": { "filter": { "url": "karriknowles" }, "paging": { "limit": 1 } }
|
|
20
|
+
}
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
By PAC member ID — **number, not string**:
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"dataCollectionId": "MembersDataLatest",
|
|
28
|
+
"query": { "filter": { "memberId": 731898 }, "paging": { "limit": 1 } }
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
By email:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"dataCollectionId": "MembersDataLatest",
|
|
37
|
+
"query": { "filter": { "email": "someone@example.com" }, "paging": { "limit": 1 } }
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
By name — `fullName` only, never the encrypted `firstName`/`lastName`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"dataCollectionId": "MembersDataLatest",
|
|
46
|
+
"query": { "filter": { "fullName": { "$contains": "Knowles" } }, "paging": { "limit": 20 } }
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Return only the fields you care about
|
|
51
|
+
|
|
52
|
+
Large records (galleries, testimonials, rich text) drown the useful bits. Project:
|
|
53
|
+
|
|
54
|
+
```json
|
|
55
|
+
{
|
|
56
|
+
"dataCollectionId": "MembersDataLatest",
|
|
57
|
+
"query": {
|
|
58
|
+
"filter": { "memberId": 949741 },
|
|
59
|
+
"fields": [
|
|
60
|
+
"memberId",
|
|
61
|
+
"url",
|
|
62
|
+
"fullName",
|
|
63
|
+
"bookingUrl",
|
|
64
|
+
"showBookingUrl",
|
|
65
|
+
"apiBookingUrl",
|
|
66
|
+
"APIBookingUrl",
|
|
67
|
+
"isVisible",
|
|
68
|
+
"action"
|
|
69
|
+
],
|
|
70
|
+
"paging": { "limit": 1 }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Count / scope an issue
|
|
76
|
+
|
|
77
|
+
How many members are affected, without pulling them all. Use the **dedicated count
|
|
78
|
+
endpoint** — `POST https://www.wixapis.com/wix-data/v2/items/count`
|
|
79
|
+
([docs](https://dev.wix.com/docs/api-reference/business-solutions/cms/data-items/count-data-items)).
|
|
80
|
+
Note the different body shape: `filter` sits at the top level, there is no `query` wrapper.
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"dataCollectionId": "MembersDataLatest",
|
|
85
|
+
"filter": { "showBookingUrl": false, "bookingUrl": { "$ne": "" } }
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Returns `{"totalCount": 82449}`.
|
|
90
|
+
|
|
91
|
+
> ⚠️ **Do not rely on `returnTotalCount` in the query endpoint.** Verified 2026-08-04 against
|
|
92
|
+
> `MembersDataLatest`: passing `"returnTotalCount": true` — with or without an explicit
|
|
93
|
+
> `paging.offset` — returns `pagingMetadata` containing `count`, `offset`, `tooManyToCount`,
|
|
94
|
+
> `cursors` and `hasNext`, but **no `total` field**. Use `/items/count` for totals.
|
|
95
|
+
|
|
96
|
+
## Members who should have been dropped but are still visible
|
|
97
|
+
|
|
98
|
+
The shape behind the "multies not renewing / expired members still listed" class of ticket:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"dataCollectionId": "MembersDataLatest",
|
|
103
|
+
"query": {
|
|
104
|
+
"filter": { "action": "drop", "isVisible": true },
|
|
105
|
+
"fields": ["memberId", "url", "fullName", "action", "isVisible", "memberships"],
|
|
106
|
+
"paging": { "limit": 100 }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Inverse — visible members whose membership already expired:
|
|
112
|
+
|
|
113
|
+
```json
|
|
114
|
+
{
|
|
115
|
+
"dataCollectionId": "MembersDataLatest",
|
|
116
|
+
"query": {
|
|
117
|
+
"filter": { "isVisible": true, "memberships.expiration": { "$lt": "2026-08-04T00:00:00" } },
|
|
118
|
+
"fields": ["memberId", "url", "memberships", "action"],
|
|
119
|
+
"paging": { "limit": 100 }
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
> ⚠️ **`memberships.expiration` is a plain ISO string, not a date.** Compare it as a string.
|
|
125
|
+
> Verified 2026-08-04 on ABMP prod: `{"$lt": {"$date": "2026-08-04T00:00:00.000Z"}}` counts
|
|
126
|
+
> **0**, while `{"$lt": "2026-08-04T00:00:00"}` counts **1008**. The `$date` wrapper is only
|
|
127
|
+
> correct for true DATETIME fields such as `_createdDate` / `_updatedDate`. Note the stored
|
|
128
|
+
> strings have no `Z` suffix and no milliseconds — match that format.
|
|
129
|
+
|
|
130
|
+
## Recently synced records
|
|
131
|
+
|
|
132
|
+
Useful for confirming whether a nightly run touched a member at all:
|
|
133
|
+
|
|
134
|
+
```json
|
|
135
|
+
{
|
|
136
|
+
"dataCollectionId": "MembersDataLatest",
|
|
137
|
+
"query": {
|
|
138
|
+
"filter": { "_updatedDate": { "$gte": { "$date": "2026-08-01T00:00:00.000Z" } } },
|
|
139
|
+
"sort": [{ "fieldName": "_updatedDate", "order": "DESC" }],
|
|
140
|
+
"fields": ["memberId", "url", "_updatedDate", "action", "pageNumber"],
|
|
141
|
+
"paging": { "limit": 50 }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Combining conditions
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"dataCollectionId": "MembersDataLatest",
|
|
151
|
+
"query": {
|
|
152
|
+
"filter": {
|
|
153
|
+
"$and": [
|
|
154
|
+
{ "isVisible": true },
|
|
155
|
+
{ "$or": [{ "showBookingUrl": true }, { "showWebsite": true }] }
|
|
156
|
+
]
|
|
157
|
+
},
|
|
158
|
+
"paging": { "limit": 25 }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Paging past 100
|
|
164
|
+
|
|
165
|
+
Offset paging (each request may carry its own filter/sort):
|
|
166
|
+
|
|
167
|
+
```json
|
|
168
|
+
{
|
|
169
|
+
"dataCollectionId": "MembersDataLatest",
|
|
170
|
+
"query": { "filter": {}, "paging": { "limit": 100, "offset": 100 } }
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Cursor paging for a long scan — set `filter`/`sort` on the **first** request only, then pass
|
|
175
|
+
back `pagingMetadata.cursors.next` alone:
|
|
176
|
+
|
|
177
|
+
```json
|
|
178
|
+
{
|
|
179
|
+
"dataCollectionId": "MembersDataLatest",
|
|
180
|
+
"query": { "cursorPaging": { "limit": 100, "cursor": "<cursors.next>" } }
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## Simulate a site query to test whether a filter really excludes something
|
|
187
|
+
|
|
188
|
+
The highest-value technique in this skill. When a ticket claims "X shouldn't be showing but is",
|
|
189
|
+
rebuild the site's own filters against `/items/count` and check whether the record survives. This
|
|
190
|
+
tests the filter's real semantics instead of reasoning about the code.
|
|
191
|
+
|
|
192
|
+
The directory query (`buildMembersSearchQuery` in `backend/cms-data-methods.js`) is:
|
|
193
|
+
|
|
194
|
+
```json
|
|
195
|
+
{
|
|
196
|
+
"dataCollectionId": "MembersDataLatest",
|
|
197
|
+
"filter": {
|
|
198
|
+
"optOut": { "$ne": true },
|
|
199
|
+
"action": { "$ne": "drop" },
|
|
200
|
+
"memberships.membertype": { "$ne": "PAC STAFF" },
|
|
201
|
+
"isVisible": true
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Add the member you are investigating and read the count. **Always run the control too** — the
|
|
207
|
+
same filter _without_ the clause under test — or a `0` tells you nothing:
|
|
208
|
+
|
|
209
|
+
```
|
|
210
|
+
with the PAC STAFF clause -> 0 (excluded)
|
|
211
|
+
without the PAC STAFF clause -> 1 (would otherwise appear)
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
To ask "does _anything_ slip through?", combine both conditions on the same field with `$and`
|
|
215
|
+
(a filter object cannot hold two operators under one key):
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"dataCollectionId": "MembersDataLatest",
|
|
220
|
+
"filter": {
|
|
221
|
+
"$and": [
|
|
222
|
+
{ "memberships.membertype": { "$contains": "PAC" } },
|
|
223
|
+
{ "memberships.membertype": { "$ne": "PAC STAFF" } },
|
|
224
|
+
{ "optOut": { "$ne": true } },
|
|
225
|
+
{ "isVisible": true }
|
|
226
|
+
]
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Nested paths into array fields (`memberships.membertype`, `addresses.postalcode`) work
|
|
232
|
+
throughout.
|
|
233
|
+
|
|
234
|
+
> Caveat proven on 2026-08-04: this validates **filter semantics**, not that the deployed site
|
|
235
|
+
> applies them. A filter can be provably correct while the live page still misbehaves. To settle
|
|
236
|
+
> that, get the site's actual API response — the browser devtools network tab is faster than any
|
|
237
|
+
> amount of reasoning, and `?nearby=true` style search UIs often need geolocation before they
|
|
238
|
+
> return anything at all.
|
|
239
|
+
|
|
240
|
+
## Other endpoints
|
|
241
|
+
|
|
242
|
+
**Collection schema** — field names, types, and which operators each field allows:
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
GET https://www.wixapis.com/wix-data/v2/collections/MembersDataLatest
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**List all collections on the site:**
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
GET https://www.wixapis.com/wix-data/v2/collections
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
**Full-text search** across fields (`POST .../wix-data/v2/items/search`) — note it takes
|
|
255
|
+
`data_collection_id` (snake_case) and a `search` object rather than `query`. Only works on
|
|
256
|
+
CMS-native collections. See
|
|
257
|
+
[Search Data Items](https://dev.wix.com/docs/api-reference/business-solutions/cms/data-items/search-data-items).
|
|
258
|
+
|
|
259
|
+
**Aggregate** (`AGGREGATE` is supported on `MembersDataLatest`) for group-by counts, e.g.
|
|
260
|
+
distribution of `action` values across the directory.
|
|
261
|
+
|
|
262
|
+
Other collections worth knowing, from `COLLECTIONS` in `public/consts.js`:
|
|
263
|
+
`SiteConfigs`, `CompiledStateCityMap`, `State`, `City`, `interests`,
|
|
264
|
+
`contactUsSubmissions`, `updatedLoginEmails`, `QA_Users`, `ButtonClicks`.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const { ADDRESS_STATUS_TYPES } = require('../../public/consts');
|
|
2
|
+
const { buildMapLink } = require('../../public/Utils/sharedUtils');
|
|
3
|
+
|
|
4
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
const fullAddress = (overrides = {}) => ({
|
|
7
|
+
line1: '1366 4th Ave',
|
|
8
|
+
line2: '',
|
|
9
|
+
city: 'Coraopolis',
|
|
10
|
+
state: 'PA',
|
|
11
|
+
postalcode: '15108-1675',
|
|
12
|
+
latitude: 40.4129180908203,
|
|
13
|
+
longitude: -80.0293197631836,
|
|
14
|
+
addressStatus: ADDRESS_STATUS_TYPES.FULL_ADDRESS,
|
|
15
|
+
...overrides,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const queryOf = url => decodeURIComponent((url.split('?q=')[1] || '').replace(/\+/g, ' '));
|
|
19
|
+
|
|
20
|
+
// ─── Monday 12596102059 ──────────────────────────────────────────────
|
|
21
|
+
// NetForum's address verifier returned coordinates ~10.8 miles from the real
|
|
22
|
+
// address for member 1806273. The directions link should resolve from the
|
|
23
|
+
// address text, which is correct, rather than from the coordinates.
|
|
24
|
+
|
|
25
|
+
describe('buildMapLink - prefers the address over coordinates', () => {
|
|
26
|
+
it('builds the link from the street address, not the lat/long', () => {
|
|
27
|
+
const url = buildMapLink(fullAddress());
|
|
28
|
+
|
|
29
|
+
expect(url).toContain('maps.google.com');
|
|
30
|
+
expect(queryOf(url)).toBe('1366 4th Ave, Coraopolis, PA, 15108');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('does not put the stored coordinates in the link when an address exists', () => {
|
|
34
|
+
const url = buildMapLink(fullAddress());
|
|
35
|
+
|
|
36
|
+
expect(url).not.toContain('40.4129');
|
|
37
|
+
expect(url).not.toContain('-80.0293');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('still resolves correctly when the coordinates are wrong', () => {
|
|
41
|
+
// Coordinates deliberately far from the address - the link must ignore them.
|
|
42
|
+
const url = buildMapLink(fullAddress({ latitude: 0, longitude: 0 }));
|
|
43
|
+
|
|
44
|
+
expect(queryOf(url)).toBe('1366 4th Ave, Coraopolis, PA, 15108');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('url-encodes the address so spaces do not break the link', () => {
|
|
48
|
+
const url = buildMapLink(fullAddress());
|
|
49
|
+
|
|
50
|
+
expect(url).not.toMatch(/\?q=.*\s/);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ─── Privacy ─────────────────────────────────────────────────────────
|
|
55
|
+
// formatAddress omits line1 for anything other than full_address. Callers gate
|
|
56
|
+
// on full_address before showing the button, but the helper must not leak a
|
|
57
|
+
// hidden street address even if it is called directly.
|
|
58
|
+
|
|
59
|
+
describe('buildMapLink - does not leak a hidden street address', () => {
|
|
60
|
+
it('omits the street line for a state_city_zip address', () => {
|
|
61
|
+
const url = buildMapLink(fullAddress({ addressStatus: ADDRESS_STATUS_TYPES.STATE_CITY_ZIP }));
|
|
62
|
+
|
|
63
|
+
expect(url).not.toContain('4th');
|
|
64
|
+
expect(queryOf(url)).toBe('Coraopolis, PA, 15108');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('falls back to coordinates rather than the street line for dont_show', () => {
|
|
68
|
+
const url = buildMapLink(fullAddress({ addressStatus: ADDRESS_STATUS_TYPES.DONT_SHOW }));
|
|
69
|
+
|
|
70
|
+
expect(url).not.toContain('4th');
|
|
71
|
+
expect(url).toBe('https://maps.google.com/?q=40.4129180908203,-80.0293197631836');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('truncates the postal code to five digits', () => {
|
|
75
|
+
const url = buildMapLink(fullAddress());
|
|
76
|
+
|
|
77
|
+
expect(url).not.toContain('1675');
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ─── Fallbacks ───────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe('buildMapLink - fallbacks', () => {
|
|
84
|
+
it('uses coordinates when the address has no printable parts', () => {
|
|
85
|
+
const url = buildMapLink({
|
|
86
|
+
line1: '',
|
|
87
|
+
line2: '',
|
|
88
|
+
city: '',
|
|
89
|
+
state: '',
|
|
90
|
+
postalcode: '',
|
|
91
|
+
latitude: 40.5,
|
|
92
|
+
longitude: -80.1,
|
|
93
|
+
addressStatus: ADDRESS_STATUS_TYPES.FULL_ADDRESS,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(url).toBe('https://maps.google.com/?q=40.5,-80.1');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('returns an empty string when there is neither an address nor coordinates', () => {
|
|
100
|
+
expect(
|
|
101
|
+
buildMapLink({
|
|
102
|
+
line1: '',
|
|
103
|
+
city: '',
|
|
104
|
+
state: '',
|
|
105
|
+
postalcode: '',
|
|
106
|
+
addressStatus: ADDRESS_STATUS_TYPES.FULL_ADDRESS,
|
|
107
|
+
})
|
|
108
|
+
).toBe('');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('returns an empty string for a missing address', () => {
|
|
112
|
+
expect(buildMapLink(null)).toBe('');
|
|
113
|
+
expect(buildMapLink(undefined)).toBe('');
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
const { isNotValidUrl } = require('../../public/Utils/personalDetailsUtils');
|
|
2
|
+
|
|
3
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const isValid = url => !isNotValidUrl(url);
|
|
6
|
+
|
|
7
|
+
// ─── Regression: digits in the hostname ──────────────────────────────
|
|
8
|
+
// Monday bug 12663709539 - a member could not save the booking link
|
|
9
|
+
// https://patty-10439.square.site because the host character class was
|
|
10
|
+
// written `[da-z.-]` instead of `[\da-z.-]`, rejecting every domain
|
|
11
|
+
// containing a digit.
|
|
12
|
+
|
|
13
|
+
describe('isNotValidUrl - digits in hostname', () => {
|
|
14
|
+
it('accepts the exact URL from the bug report', () => {
|
|
15
|
+
expect(isValid('https://patty-10439.square.site')).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it.each([
|
|
19
|
+
'https://my-spa123.com',
|
|
20
|
+
'https://massage4u.net',
|
|
21
|
+
'https://booksy.com/en-us/698924_therapist_health-fitness_119607_city',
|
|
22
|
+
'https://www.genbook.com/bookings/slot/reservation/30241562?bookingSourceId=1000',
|
|
23
|
+
'www.spa2go.com',
|
|
24
|
+
'https://123.example.com',
|
|
25
|
+
])('accepts %s', url => {
|
|
26
|
+
expect(isValid(url)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('isNotValidUrl - case insensitivity', () => {
|
|
31
|
+
it.each(['https://Patty-10439.Square.Site', 'HTTPS://EXAMPLE.COM', 'WWW.Example.Com'])(
|
|
32
|
+
'accepts %s',
|
|
33
|
+
url => {
|
|
34
|
+
expect(isValid(url)).toBe(true);
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// ─── Guard against regressions in the previously-working cases ───────
|
|
40
|
+
|
|
41
|
+
describe('isNotValidUrl - previously valid URLs stay valid', () => {
|
|
42
|
+
it.each([
|
|
43
|
+
'https://square.site',
|
|
44
|
+
'https://patty.square.site',
|
|
45
|
+
'http://healinghut.massagetherapy.com',
|
|
46
|
+
'www.example.com',
|
|
47
|
+
'https://example.co.uk',
|
|
48
|
+
'https://example.com/path/to/page',
|
|
49
|
+
'https://example.com?foo=bar',
|
|
50
|
+
'https://example.com#anchor',
|
|
51
|
+
])('accepts %s', url => {
|
|
52
|
+
expect(isValid(url)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('isNotValidUrl - invalid input is still rejected', () => {
|
|
57
|
+
it.each(['not a url', 'example', 'ftp://example.com', 'justtext.c', 'http://'])(
|
|
58
|
+
'rejects %s',
|
|
59
|
+
url => {
|
|
60
|
+
expect(isValid(url)).toBe(false);
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
it('treats an empty value as valid because the field is optional', () => {
|
|
65
|
+
expect(isValid('')).toBe(true);
|
|
66
|
+
expect(isValid(undefined)).toBe(true);
|
|
67
|
+
expect(isValid(null)).toBe(true);
|
|
68
|
+
});
|
|
69
|
+
});
|
package/package.json
CHANGED
package/pages/Home.js
CHANGED
|
@@ -3,20 +3,15 @@ const { location: wixLocation } = require('@wix/site-location');
|
|
|
3
3
|
const { window: wixWindow, rendering } = require('@wix/site-window');
|
|
4
4
|
const { withWarmUpData } = require('psdev-utils/frontend');
|
|
5
5
|
|
|
6
|
-
const {
|
|
7
|
-
ADDRESS_STATUS_TYPES,
|
|
8
|
-
DEFAULT_FILTER,
|
|
9
|
-
DROPDOWN_OPTIONS,
|
|
10
|
-
LIGHTBOX_NAMES,
|
|
11
|
-
} = require('../public/consts.js');
|
|
6
|
+
const { ADDRESS_STATUS_TYPES, DEFAULT_FILTER, DROPDOWN_OPTIONS } = require('../public/consts.js');
|
|
12
7
|
const { createHomepageUtils } = require('../public/Utils/homePage.js');
|
|
13
8
|
const {
|
|
14
9
|
getMainAddress,
|
|
15
|
-
findMainAddress,
|
|
16
|
-
checkAddressIsVisible,
|
|
17
10
|
formatPracticeAreasForDisplay,
|
|
11
|
+
checkAddressIsVisible,
|
|
18
12
|
isWixHostedImage,
|
|
19
13
|
normalizeExternalUrl,
|
|
14
|
+
buildMapLink,
|
|
20
15
|
} = require('../public/Utils/sharedUtils.js');
|
|
21
16
|
|
|
22
17
|
let filter = JSON.parse(JSON.stringify(DEFAULT_FILTER));
|
|
@@ -36,28 +31,6 @@ const pagination = {
|
|
|
36
31
|
let searchResults = [];
|
|
37
32
|
let isMobile = false;
|
|
38
33
|
|
|
39
|
-
const isMappable = addr =>
|
|
40
|
-
addr?.addressStatus === ADDRESS_STATUS_TYPES.FULL_ADDRESS && addr.latitude && addr.longitude;
|
|
41
|
-
|
|
42
|
-
// The button is only offered when the member has at least one mappable address, which is
|
|
43
|
-
// the same condition as before the lightbox was introduced. When that address is not the
|
|
44
|
-
// primary one we still show the button but withhold the link, so the click opens the
|
|
45
|
-
// lightbox instead of sending the visitor to a secondary address in another city.
|
|
46
|
-
const getDirectionsTarget = itemData => {
|
|
47
|
-
const addresses = Array.isArray(itemData?.addresses) ? itemData.addresses : [];
|
|
48
|
-
const showButton = checkAddressIsVisible(addresses).some(isMappable);
|
|
49
|
-
if (!showButton) {
|
|
50
|
-
return { showButton: false, mapsLink: null };
|
|
51
|
-
}
|
|
52
|
-
const mainAddr = findMainAddress(itemData?.addressDisplayOption, addresses);
|
|
53
|
-
return {
|
|
54
|
-
showButton: true,
|
|
55
|
-
mapsLink: isMappable(mainAddr)
|
|
56
|
-
? `https://maps.google.com/?q=${mainAddr.latitude},${mainAddr.longitude}`
|
|
57
|
-
: null,
|
|
58
|
-
};
|
|
59
|
-
};
|
|
60
|
-
|
|
61
34
|
const homePageOnReady = async ({
|
|
62
35
|
_$w,
|
|
63
36
|
getCompiledFiltersOptions,
|
|
@@ -209,16 +182,6 @@ const homePageOnReady = async ({
|
|
|
209
182
|
await updateResults('zeroTimeout');
|
|
210
183
|
});
|
|
211
184
|
const baseUrl = await wixLocation.baseUrl();
|
|
212
|
-
_$w('#showMaps').onClick(event => {
|
|
213
|
-
const member = searchResults.find(result => result._id === event.context.itemId);
|
|
214
|
-
if (!member) {
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const { showButton, mapsLink } = getDirectionsTarget(member);
|
|
218
|
-
if (showButton && !mapsLink) {
|
|
219
|
-
wixWindow.openLightbox(LIGHTBOX_NAMES.CONTACT_FOR_LOCATION, member);
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
185
|
_$w('#profileRepeater').onItemReady(($item, itemData) => {
|
|
223
186
|
// 1) safely default to arrays
|
|
224
187
|
const addresses = Array.isArray(itemData.addresses) ? itemData.addresses : [];
|
|
@@ -263,24 +226,33 @@ const homePageOnReady = async ({
|
|
|
263
226
|
$item('#milesAwayText').text = '';
|
|
264
227
|
}
|
|
265
228
|
|
|
266
|
-
// 7) "Show maps" button
|
|
267
|
-
|
|
268
|
-
const
|
|
229
|
+
// 7) "Show maps" button shown only when we can actually build a link for it
|
|
230
|
+
const visible = checkAddressIsVisible(addresses);
|
|
231
|
+
const fullAddressWithValidCoords = visible.find(
|
|
232
|
+
addr =>
|
|
233
|
+
addr.addressStatus === ADDRESS_STATUS_TYPES.FULL_ADDRESS &&
|
|
234
|
+
addr.latitude &&
|
|
235
|
+
addr.longitude
|
|
236
|
+
);
|
|
269
237
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
238
|
+
// Links to the street address rather than the stored coordinates, which are
|
|
239
|
+
// unreliable for some members. The full-address filter above is what keeps
|
|
240
|
+
// this safe: members set to state_city_zip or dont_show never reach here, so
|
|
241
|
+
// a hidden street address is never put in a maps URL.
|
|
242
|
+
//
|
|
243
|
+
// Gate on the link rather than on the address. buildMapLink returns '' when
|
|
244
|
+
// it can build neither an address nor a coordinate link, and it also handles
|
|
245
|
+
// being passed undefined - so this cannot leave a visible button linking
|
|
246
|
+
// nowhere, even if the filter above is later relaxed.
|
|
247
|
+
const mapLink = buildMapLink(fullAddressWithValidCoords);
|
|
248
|
+
|
|
249
|
+
if (mapLink) {
|
|
273
250
|
$item('#showMaps').enable();
|
|
274
251
|
$item('#showMaps').show();
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
} else {
|
|
280
|
-
// Clear any link left over from a recycled repeater item, otherwise the
|
|
281
|
-
// button would still navigate to the previous member's address.
|
|
282
|
-
$item('#showMaps').link = undefined;
|
|
283
|
-
}
|
|
252
|
+
$item('#showMaps').link = mapLink;
|
|
253
|
+
$item('#showMaps').target = '_blank';
|
|
254
|
+
} else {
|
|
255
|
+
$item('#showMaps').hide();
|
|
284
256
|
}
|
|
285
257
|
|
|
286
258
|
// 8) Phone / contact form
|
package/pages/index.js
CHANGED
|
@@ -13,8 +13,12 @@ function isNotValidUrl(url) {
|
|
|
13
13
|
if (!url) return false;
|
|
14
14
|
|
|
15
15
|
// URL must start with protocol OR www - handles all TLDs including multi-level and query params
|
|
16
|
+
// NOTE: the host class is `[\da-z.-]` (digit, letter, dot, hyphen). It previously read
|
|
17
|
+
// `[da-z.-]`, which - missing the backslash - matched only a literal "d" plus a-z, so any
|
|
18
|
+
// domain containing a digit was rejected (e.g. https://patty-10439.square.site).
|
|
19
|
+
// The `i` flag keeps mixed-case hosts valid; domains are case-insensitive.
|
|
16
20
|
const urlRegex =
|
|
17
|
-
/^(https?:\/\/|www\.)([da-z.-]+)\.([a-z.]{2,})([/\w .-]*)*(\?[&\w=.-]*)?(#[&\w=.-]*)
|
|
21
|
+
/^(https?:\/\/|www\.)([\da-z.-]+)\.([a-z.]{2,})([/\w .-]*)*(\?[&\w=.-]*)?(#[&\w=.-]*)?\/?$/i;
|
|
18
22
|
|
|
19
23
|
return !urlRegex.test(url);
|
|
20
24
|
}
|
|
@@ -118,6 +118,37 @@ function formatAddress(item) {
|
|
|
118
118
|
return addressParts.filter(Boolean).join(', ');
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Builds the outbound map link for an address.
|
|
123
|
+
*
|
|
124
|
+
* Prefers the street address over the stored coordinates. NetForum's address
|
|
125
|
+
* verifier (Cdyne) sometimes returns coordinates that are miles away from the
|
|
126
|
+
* real address, and some coordinates were edited by hand in the past to change
|
|
127
|
+
* directory ranking, so the address text is the more reliable of the two.
|
|
128
|
+
* Distance ranking and the "XX miles away" figure keep using the coordinates -
|
|
129
|
+
* that is a separate calculation (see calculateDistance) and is unaffected.
|
|
130
|
+
*
|
|
131
|
+
* Falls back to coordinates only when the address cannot be formatted, so the
|
|
132
|
+
* button never links nowhere.
|
|
133
|
+
*
|
|
134
|
+
* @param {Object} address - a single address entry
|
|
135
|
+
* @returns {string} map URL, or '' when neither an address nor coordinates exist
|
|
136
|
+
*/
|
|
137
|
+
function buildMapLink(address) {
|
|
138
|
+
if (!address) return '';
|
|
139
|
+
|
|
140
|
+
const query = formatAddress(address);
|
|
141
|
+
if (query) {
|
|
142
|
+
return `https://maps.google.com/?q=${encodeURIComponent(query)}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (isValidLocation(address)) {
|
|
146
|
+
return `https://maps.google.com/?q=${address.latitude},${address.longitude}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
151
|
+
|
|
121
152
|
/**
|
|
122
153
|
* @param {Array} addressDisplayOption
|
|
123
154
|
* @param {Array} addresses
|
|
@@ -228,6 +259,7 @@ module.exports = {
|
|
|
228
259
|
toRadians,
|
|
229
260
|
generateId,
|
|
230
261
|
formatAddress,
|
|
262
|
+
buildMapLink,
|
|
231
263
|
isWixHostedImage,
|
|
232
264
|
normalizeExternalUrl,
|
|
233
265
|
normalizeEmail,
|
package/public/consts.js
CHANGED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
const { location: wixLocation } = require('@wix/site-location');
|
|
2
|
-
const { lightbox } = require('@wix/site-window');
|
|
3
|
-
|
|
4
|
-
const { PAGES_PATHS } = require('../public/consts.js');
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Shown from the directory when a member's primary address is not a full address,
|
|
8
|
-
* so "Directions" cannot be offered. Sends the visitor to the member's profile
|
|
9
|
-
* page rather than opening the contact form.
|
|
10
|
-
*/
|
|
11
|
-
async function contactForLocationOnReady({ $w: _$w }) {
|
|
12
|
-
const member = await lightbox.getContext();
|
|
13
|
-
const profilePath = member?.url;
|
|
14
|
-
|
|
15
|
-
if (!profilePath) {
|
|
16
|
-
_$w('#contact').hide();
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
_$w('#contact').onClick(async () => {
|
|
21
|
-
await lightbox.close();
|
|
22
|
-
wixLocation.to(`/${PAGES_PATHS.PROFILE}/${profilePath}`);
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
module.exports = {
|
|
27
|
-
contactForLocationOnReady,
|
|
28
|
-
};
|