abmp-npm 10.3.13 → 10.3.15

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,121 @@
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
+ ## 5. Rules
110
+
111
+ - **Read-only by default.** Query, count, distinct, aggregate, get-schema are all fine to run
112
+ unprompted during an investigation.
113
+ - **Never write to a production collection without explicit approval in this conversation.**
114
+ Inserts, updates, patches, and `TRUNCATE` change live member-facing data. State exactly what
115
+ you intend to change and on which site, and wait for a yes. When a fix needs testing, use the
116
+ Test site IDs above.
117
+ - **Don't paste member PII into external systems.** These records contain real names, emails,
118
+ phone numbers, and home addresses. Quote the minimum needed — a member ID and the one wrong
119
+ field — when writing a Monday comment or a commit message.
120
+ - Ground endpoints in docs, not memory. If you need an endpoint this skill doesn't cover, find
121
+ it with `SearchWixRESTDocumentation` first.
@@ -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,210 @@
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
+ ## Other endpoints
187
+
188
+ **Collection schema** — field names, types, and which operators each field allows:
189
+
190
+ ```
191
+ GET https://www.wixapis.com/wix-data/v2/collections/MembersDataLatest
192
+ ```
193
+
194
+ **List all collections on the site:**
195
+
196
+ ```
197
+ GET https://www.wixapis.com/wix-data/v2/collections
198
+ ```
199
+
200
+ **Full-text search** across fields (`POST .../wix-data/v2/items/search`) — note it takes
201
+ `data_collection_id` (snake_case) and a `search` object rather than `query`. Only works on
202
+ CMS-native collections. See
203
+ [Search Data Items](https://dev.wix.com/docs/api-reference/business-solutions/cms/data-items/search-data-items).
204
+
205
+ **Aggregate** (`AGGREGATE` is supported on `MembersDataLatest`) for group-by counts, e.g.
206
+ distribution of `action` values across the directory.
207
+
208
+ Other collections worth knowing, from `COLLECTIONS` in `public/consts.js`:
209
+ `SiteConfigs`, `CompiledStateCityMap`, `State`, `City`, `interests`,
210
+ `contactUsSubmissions`, `updatedLoginEmails`, `QA_Users`, `ButtonClicks`.
@@ -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": "10.3.13",
3
+ "version": "10.3.15",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "check-cycles": "madge --circular .",
@@ -975,36 +975,10 @@ async function personalDetailsOnReady({
975
975
  });
976
976
  }
977
977
 
978
- async function handleItemDelete(event, getTextSelector, arrayRef, matchField, renderFn) {
979
- const result = await wixWindow.openLightbox(LIGHTBOX_NAMES.DELETE_CONFIRM);
980
-
981
- if (result && result.toDelete) {
982
- const $clickedItem = _$w.at(event.context);
983
- const textToRemove = $clickedItem(getTextSelector).text;
984
-
985
- arrayRef.splice(
986
- 0,
987
- arrayRef.length,
988
- ...arrayRef.filter(item =>
989
- typeof item === 'string' ? item !== textToRemove : item[matchField] !== textToRemove
990
- )
991
- );
992
-
993
- renderFn();
994
- checkFormChanges(FORM_SECTION_HANDLER_MAP.BUSINESS_SERVICES);
995
- }
996
- }
997
-
998
978
  async function setInterestData() {
999
979
  const interestsData = await getInterestAll();
1000
980
 
1001
981
  _$w('#removeServiceButton').onClick(async event => {
1002
- // Capture the stable item id BEFORE awaiting the confirm lightbox.
1003
- // Reading the chip's text after the await is unsafe: the repeater
1004
- // recycles its DOM items on re-render, so after a rapid sequence of
1005
- // deletes event.context can resolve to a recycled node showing a
1006
- // different service, removing the wrong item (or none). Matching on
1007
- // the stable _id (as the gallery delete does) avoids that race.
1008
982
  const itemId = event.context.itemId;
1009
983
  const result = await wixWindow.openLightbox(LIGHTBOX_NAMES.DELETE_CONFIRM);
1010
984
 
@@ -1090,9 +1064,6 @@ async function personalDetailsOnReady({
1090
1064
  }
1091
1065
 
1092
1066
  function renderServices() {
1093
- // Pass a fresh array copy so the Wix repeater detects the change and
1094
- // re-renders. Assigning the same array reference (mutated in place by
1095
- // add/delete) is treated as a no-op, leaving stale chips on screen.
1096
1067
  setupRepeater('#servicesRepeater', [...selectedServices]);
1097
1068
  }
1098
1069
 
@@ -1291,9 +1262,10 @@ async function personalDetailsOnReady({
1291
1262
  console.groupEnd();
1292
1263
 
1293
1264
  const result = await saveData(formData);
1294
- formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.PERSONAL.section] = false;
1295
1265
 
1296
1266
  if (result.success) {
1267
+ formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.PERSONAL.section] = false;
1268
+
1297
1269
  if (personalChanges.url && personalChanges.url !== originalUrl) {
1298
1270
  const newProfileLink = `${baseUrl}/profile/${personalChanges.url}`;
1299
1271
  console.log('🔗 Updating profile link:', {
@@ -1346,7 +1318,10 @@ async function personalDetailsOnReady({
1346
1318
  console.groupEnd();
1347
1319
 
1348
1320
  const result = await saveData(formData);
1349
- formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.BUSINESS_SERVICES.section] = false;
1321
+ if (result.success) {
1322
+ formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.BUSINESS_SERVICES.section] = false;
1323
+ _$w('#saveBusinessButton').disable();
1324
+ }
1350
1325
  handleSaveDataFeedback(_$w('#businessMessage'), result.message);
1351
1326
  _$w('#businessNameText').text = formData.businessName || DEFAULT_BUSINESS_NAME_TEXT;
1352
1327
  }
@@ -1355,14 +1330,21 @@ async function personalDetailsOnReady({
1355
1330
  const addTestimonialButton = _$w('#addTestimonialButton');
1356
1331
 
1357
1332
  addTestimonialButton.onClick(handleAddTestimonial);
1358
- _$w('#deleteTestimonialButton').onClick(event => {
1359
- handleItemDelete(
1360
- event,
1361
- '#testimonialText',
1362
- itemMemberObj.testimonial,
1363
- null,
1364
- renderTestimonials
1365
- );
1333
+ _$w('#deleteTestimonialButton').onClick(async event => {
1334
+ const data = _$w('#testimonialRepeater').data || [];
1335
+ const clickedIndex = data.findIndex(item => item._id === event.context.itemId);
1336
+ const result = await wixWindow.openLightbox(LIGHTBOX_NAMES.DELETE_CONFIRM);
1337
+
1338
+ if (
1339
+ result &&
1340
+ result.toDelete &&
1341
+ clickedIndex > 0 &&
1342
+ Array.isArray(itemMemberObj.testimonial)
1343
+ ) {
1344
+ itemMemberObj.testimonial.splice(clickedIndex - 1, 1);
1345
+ renderTestimonials();
1346
+ checkFormChanges(FORM_SECTION_HANDLER_MAP.BUSINESS_SERVICES);
1347
+ }
1366
1348
  });
1367
1349
 
1368
1350
  renderTestimonials();
@@ -2402,8 +2384,9 @@ async function personalDetailsOnReady({
2402
2384
  // Sync Personal Details opt-in from saved member.
2403
2385
  _$w('#optWebsiteCheckbox').checked = itemMemberObj.showWixUrl;
2404
2386
  toggleFreeWebsiteText(itemMemberObj.showWixUrl);
2387
+ formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.CONTACT_BOOKING.section] = false;
2388
+ _$w('#saveContactBookingButton').disable();
2405
2389
  }
2406
- formHasUnsavedChanges[FORM_SECTION_HANDLER_MAP.CONTACT_BOOKING.section] = false;
2407
2390
  handleSaveDataFeedback(_$w('#contactMessage'), result.message);
2408
2391
  }
2409
2392
 
@@ -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
  }