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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "abmp-npm",
3
- "version": "2.0.76",
3
+ "version": "2.0.77",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "check-cycles": "madge --circular .",
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. Links to the primary address when it is a full address;
267
- // otherwise it stays visible and the page-level onClick opens a lightbox.
268
- const { showButton, mapsLink } = getDirectionsTarget(itemData);
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
- if (!showButton) {
271
- $item('#showMaps').hide();
272
- } else {
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
- if (mapsLink) {
277
- $item('#showMaps').link = mapsLink;
278
- $item('#showMaps').target = '_blank';
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
@@ -9,5 +9,4 @@ module.exports = {
9
9
  ...require('./deleteConfirm.js'),
10
10
  ...require('./SaveAlerts.js'),
11
11
  ...require('./LearnMore.js'),
12
- ...require('./contactForLocation.js'),
13
12
  };
@@ -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
@@ -85,7 +85,6 @@ const LIGHTBOX_NAMES = {
85
85
  SELECT_BANNER_IMAGES: 'Select Banner Images',
86
86
  CONTACT_US: 'Contact Us',
87
87
  MAIN_ADDRESS_ERROR: 'mainAddressError',
88
- CONTACT_FOR_LOCATION: 'contactForLocation',
89
88
  };
90
89
 
91
90
  const FREE_WEBSITE_TEXT_STATES = {
@@ -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
- };