notify-me-wl 1.4.2 → 1.4.4

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.
Files changed (5) hide show
  1. package/CLAUDE.md +199 -0
  2. package/README.md +19 -8
  3. package/index.html +18 -5
  4. package/index.js +406 -216
  5. package/package.json +1 -1
package/CLAUDE.md ADDED
@@ -0,0 +1,199 @@
1
+ # CLAUDE.md — The Wishlist Company (TWC / Wishlist.io)
2
+
3
+ This file gives Claude Code persistent context about the TWC platform so every session starts informed. Update it as the platform evolves.
4
+
5
+ ---
6
+
7
+ ## This Repository
8
+
9
+ **Repo:** `notify-me_coming-soon-wl-widget`
10
+ **GitHub:** https://github.com/The-Wishlist-Co/notify-me_coming-soon-wl-widget
11
+
12
+ Customer-facing widget for 'Notify Me' and 'Coming Soon' wishlist functionality. Embedded on retailer product pages.
13
+
14
+ ---
15
+
16
+ ---
17
+
18
+ ## Platform Overview
19
+
20
+ **Wishlist.io** is a SaaS retail platform focused on capturing and activating consumer intent data, primarily in fashion retail. It surfaces wishlist and engagement data to frontline retail staff and powers personalised outreach via messaging templates.
21
+
22
+ **GitHub org:** https://github.com/The-Wishlist-Co
23
+
24
+ ---
25
+
26
+ ## Service Architecture
27
+
28
+ Microservices stack deployed on **AWS EKS**, service mesh via **Istio + Consul**. Netflix OSS for gateway/resilience. All services are **Spring Boot / JHipster**, Java 13.
29
+
30
+ | Service | Role |
31
+ |---|---|
32
+ | `twcgateway` | API gateway (Zuul) |
33
+ | `wsservice` | Wishlist core service |
34
+ | `customerservice` | Customer profile service |
35
+ | `productsvc` | Product catalogue service |
36
+ | `eventcollector` | Event ingestion |
37
+ | `inventorysvc` | Inventory service |
38
+ | `merchantsvc` | Merchant/retailer management |
39
+ | `ordersvc` | Order service |
40
+
41
+ **Observability:** OpenTelemetry + Datadog. Logs via Filebeat → Elasticsearch (`twc-ops` cluster, ECK on EKS, Elasticsearch 8.3.3, 4-node).
42
+
43
+ ---
44
+
45
+ ## Critical Infrastructure Constraints
46
+
47
+ ### Java / JVM (ALL services)
48
+
49
+ All services run Java 13 in containers and **cannot auto-detect CPU limits correctly via cgroups**. Every service deployment MUST include:
50
+
51
+ ```
52
+ -XX:ActiveProcessorCount=N
53
+ ```
54
+
55
+ in `JAVA_OPTS`, where N matches the container's CPU allocation. Omitting this causes Undertow thread pool over-sizing → Consul health check failures → Hystrix circuit breaker trips → 500 SHORTCIRCUIT errors at the gateway.
56
+
57
+ Affected services (apply to all eight): `twcgateway`, `wsservice`, `customerservice`, `productsvc`, `eventcollector`, `inventorysvc`, `merchantsvc`, `ordersvc`.
58
+
59
+ ### Elasticsearch / ECK
60
+
61
+ - Cluster: `twc-ops`, 4 nodes on EKS via ECK operator
62
+ - **Memory:** each node must be configured with **4Gi** (`-Xms2g -Xmx2g`). Nodes with 2Gi will OOM-kill under load.
63
+ - **ILM:** Filebeat data stream indices require an ILM policy with **3-day retention** to prevent disk exhaustion.
64
+ - Yellow cluster state will block ECK rolling restarts — resolve unassigned shards before patching the CR.
65
+
66
+ ### EKS Scaling Lambda
67
+
68
+ A Lambda (`eks_node_scaleup`) has hardcoded node group values and will override manual EKS scaling changes. Do not rely on manual scaling being durable — any fix must go into the Lambda source.
69
+
70
+ ---
71
+
72
+ ## Data Architecture
73
+
74
+ ### ClickHouse (Analytics / Reporting)
75
+
76
+ Used for conversion reporting and analytics. All ETL queries follow strict conventions — see Query Conventions below.
77
+
78
+ ### DynamoDB (Operational)
79
+
80
+ Key tables:
81
+
82
+ | Table | PK | SK | Notes |
83
+ |---|---|---|---|
84
+ | `TWCTEMPLATE_TOPIC` | `tenantId` | `topicId` | Soft-delete via `active` flag |
85
+ | `MESSAGE` | — | — | `messageTopic` stored directly at send time — single source of truth |
86
+
87
+ `messageTopic` is captured on the MESSAGE record at send time and is **not** looked up from `TWCTEMPLATE_TOPIC` at query time. This is intentional.
88
+
89
+ ---
90
+
91
+ ## ClickHouse Query Conventions
92
+
93
+ These conventions are non-negotiable. All new queries must follow them.
94
+
95
+ ### Named Queries (Conversion Reporting Dashboard)
96
+
97
+ | Query ID | Description |
98
+ |---|---|
99
+ | `CONV_KPI_SUMMARY` | Top-level KPI summary |
100
+ | `CONV_TREND_DAILY` | Daily trend over period |
101
+ | `CONV_BREAKDOWN_STORE` | Breakdown by store |
102
+ | `CONV_BREAKDOWN_STAFF` | Breakdown by staff |
103
+ | `CONV_BREAKDOWN_TOPIC` | Breakdown by message topic |
104
+ | `CONV_BREAKDOWN_TEMPLATE_TAG` | Breakdown by template tag |
105
+ | `CONV_BREAKDOWN_CUSTOMER_TAG` | Breakdown by customer tag |
106
+ | `CONV_BREAKDOWN_CHANNEL` | Breakdown by channel |
107
+
108
+ ### API Parameter Naming Convention
109
+
110
+ | Parameter | Maps to |
111
+ |---|---|
112
+ | `value1` | channel |
113
+ | `value2` | topic |
114
+ | `value3` | templateTag |
115
+ | `value4` | customerTag |
116
+ | `param5` | storeRefs (array) |
117
+ | `param6` | staffRefs (array) |
118
+
119
+ ### Filter Syntax Rules
120
+
121
+ **`storeRef` and `staffRef` are ALWAYS multi-select.** These are never single-value filters.
122
+
123
+ ```sql
124
+ -- ✅ CORRECT — always use IN with empty() guard
125
+ AND (empty({param5: Array(String)}) OR storeRef IN {param5: Array(String)})
126
+ AND (empty({param6: Array(String)}) OR staffRef IN {param6: Array(String)})
127
+
128
+ -- ❌ WRONG — never use equality for store or staff
129
+ AND storeRef = {param5: String}
130
+ AND staffRef = {param6: String}
131
+ ```
132
+
133
+ All other filters (`value1`–`value4`) follow the same array pattern if they are multi-select dropdowns.
134
+
135
+ ---
136
+
137
+ ## Integrations
138
+
139
+ ### Shopify
140
+
141
+ - Custom pixel integration on `twc-fashion-demo.myshopify.com` (pixel `87425273`)
142
+ - Pixels require `analytics.subscribe('init', ...)` capture and `Content-Type: application/json` header
143
+ - App updates auto-deploy to all merchants unless new OAuth permission scopes are required
144
+
145
+ ### Gorgias
146
+
147
+ Backend proxy integration surfacing support ticket data on customer profile screens.
148
+
149
+ - Per-customer credentials stored encrypted (AES-256) in `gorgias_credentials` table
150
+ - Ticket data cached in `gorgias_ticket_cache`
151
+ - Cache TTLs: 15 min (summary), 2 min (full ticket list)
152
+ - Five API endpoints for credential management and ticket retrieval
153
+
154
+ ### Emarsys
155
+
156
+ Marketing automation platform integration. Customer data synced from TWC to Emarsys for campaign targeting. Webhook updates received via `emarsys-webhook-updates` Lambda.
157
+
158
+ ### Newstore
159
+
160
+ POS platform integration. TWC builds plugins/webviews for the Newstore POS environment. Webhooks received via `twc-newstore-webhook`. Proxy via `infinityAPI` for Triquestra Infinity POS.
161
+
162
+ ---
163
+
164
+ ## Development Conventions
165
+
166
+ ### General
167
+
168
+ - Full-stack ownership: data modelling, API design, backend, infra/DevOps, frontend
169
+ - Prefer explicit over implicit — document intent in code comments for complex query logic
170
+ - All schema changes should consider soft-delete patterns (see `TWCTEMPLATE_TOPIC` above)
171
+ - Default branch: `main`
172
+ - Feature branches cut from `main`, kebab-case description (e.g. `suppression-change`)
173
+ - PR back to `main` when complete
174
+
175
+ ### Code Review Checklist (for Claude Code to apply)
176
+
177
+ Before suggesting or approving any change, verify:
178
+
179
+ - [ ] All ClickHouse queries touching store/staff use `IN` + `empty()` guard, not `=`
180
+ - [ ] Any new or modified Spring Boot service includes `-XX:ActiveProcessorCount` in JAVA_OPTS config
181
+ - [ ] DynamoDB table designs include `tenantId` as PK for all multi-tenant tables
182
+ - [ ] New Elasticsearch indices have an ILM policy assigned
183
+ - [ ] API parameter names follow the `value1`–`value4` / `param5`/`param6` convention
184
+
185
+ ---
186
+
187
+ ## Key External References
188
+
189
+ - Anthropic Claude Code docs: https://docs.claude.com/en/docs/claude-code/overview
190
+ - ECK operator docs: https://www.elastic.co/guide/en/cloud-on-k8s/current/index.html
191
+ - Shopify custom pixels: https://shopify.dev/docs/api/web-pixels-api
192
+
193
+ ---
194
+
195
+ *Last updated: May 2026. Keep this file current as the platform evolves — it is the primary context source for all Claude Code sessions.*
196
+
197
+ ## References
198
+ - **Engineering Standards:** https://github.com/The-Wishlist-Co/twc-api-specs/blob/main/standards/TWC-STANDARDS.md
199
+ - **API Specifications:** https://github.com/The-Wishlist-Co/twc-api-specs/blob/main/apis/
package/README.md CHANGED
@@ -3,10 +3,13 @@
3
3
  This JavaScript widget allows users to register their interest in a product that is either "coming soon" or "out of stock" (aka "notify me"). Depending on the `data-type` attribute provided, the widget will display appropriate messages and functionality.
4
4
 
5
5
  #### Features:
6
- - Dynamically display a form to collect user information (mobile, first name, last name).
7
- - Adjust form and button text based on the widget type (`notify-me` or `coming-soon`).
8
- - Submit user data to a specified API endpoint with email or mobile as a path parameter and appropriate query parameters.
9
- - An option for the user to subscribe to the database (opt-in)
6
+ - Clean, centered modal that inherits the host store's font and stays out of the theme's way (all styles are namespaced under `#twc-nm-overlay`).
7
+ - Dynamically display a labeled form to collect user information (email, mobile, first/last name, country, province).
8
+ - Adjust form copy and button text based on the widget type (`notify-me` or `coming-soon`).
9
+ - Inline success/error feedback (no browser `alert`s); the submit button shows a "Sending…" loading state.
10
+ - Closes on the × button, clicking the backdrop, or pressing `Esc`; locks background scroll while open and respects `prefers-reduced-motion`.
11
+ - Optional email/SMS marketing opt-ins.
12
+ - Two auth modes: bundled server token (default) or Shopify App Proxy — see [Configuration](#configuration).
10
13
 
11
14
  ### Installation
12
15
 
@@ -27,8 +30,16 @@ Add a button to your HTML where you want the widget to appear. The button should
27
30
 
28
31
  ### Configuration
29
32
 
30
- - `data-fields`: A JSON array specifying the fields to include in the form. Possible values are `"mobile"`, `"firstName"`, and `"lastName"`.
33
+ - `data-fields`: A JSON array specifying the optional fields to include in the form. Possible values are `"email"`, `"mobile"`, `"firstName"`, `"lastName"`, `"countryCode"`, and `"provinceCode"`. Defaults to `["email"]`. (A required Size selector is always shown and populated from the product's variants.)
31
34
  - `data-type`: Specifies the type of the widget. Possible values are `"notify-me"` and `"coming-soon"`.
35
+ - `data-auth`: Auth mode. `"token"` (default) uses the bundled server-issued access token. `"proxy"` uses the Shopify App Proxy at `/apps/twc-sdk/auth/token` to obtain a tenant-scoped token (requires the customer to be logged in to the storefront).
36
+ - `data-tenant`: The TWC tenant sent as the `X-Twc-Tenant` header. Used in both auth modes. Falls back to the bundled `TENANT_ID` if omitted.
37
+
38
+ ### Proxy auth mode
39
+
40
+ When `data-auth="proxy"` is set, the widget does not use the bundled access token. Instead, on form submit it lazily fetches a tenant-scoped access token from the same-origin Shopify App Proxy endpoint `/apps/twc-sdk/auth/token` (Shopify signs and forwards the request). The token is cached for the page session and reused across submissions.
41
+
42
+ Because the proxy only issues a token for a logged-in customer, if the token cannot be obtained the popup stays open and shows an inline "Please log in to your account to continue." message; submission is blocked until a token is available.
32
43
 
33
44
  ### Example
34
45
 
@@ -44,10 +55,10 @@ Add a button to your HTML where you want the widget to appear. The button should
44
55
  ### How It Works
45
56
 
46
57
  1. **Initialization**: The script listens for the `DOMContentLoaded` event to initialize the widget.
47
- 2. **Styles Injection**: It injects necessary styles for the modal and form elements.
58
+ 2. **Styles Injection**: It injects the modal/form styles, all namespaced under `#twc-nm-overlay` so they don't collide with the host theme.
48
59
  3. **Modal Creation**: Creates the modal structure and appends it to the `notification-widget` div.
49
- 4. **Dynamic Content**: Based on the `data-type`, it sets the appropriate messages and button text.
50
- 5. **Form Submission**: Collects form data and submits it to the API with the mandatory email field and other possible fields.
60
+ 4. **Dynamic Content**: Based on the `data-type`, it sets the appropriate copy and button text, and builds the fields listed in `data-fields` plus the always-present Size selector.
61
+ 5. **Form Submission**: On submit, the button enters a "Sending…" state, the configured auth token is resolved, and the form data is POSTed to the API. The result is shown inline (success message then auto-close, or an error message); the popup is never closed on failure.
51
62
 
52
63
  ### API Request Example
53
64
 
package/index.html CHANGED
@@ -6,16 +6,29 @@
6
6
  <title>Notify me</title>
7
7
  <style>
8
8
  body {
9
- height: 100vh;
9
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
10
10
  margin: 100px auto;
11
- width: 300px;
11
+ max-width: 320px;
12
12
  }
13
13
  </style>
14
14
  </head>
15
15
  <body>
16
16
  <div id="notification-widget">
17
- <button id="popup-open" class="notification-btn" type="button" data-type="notify-me" data-fields='["firstName", "lastName"]'>Notify Me - Join Waitlist</button>
18
- </div>
17
+ <button id="popup-open" class="notification-btn" type="button" data-type="notify-me" data-fields='["email", "firstName", "lastName"]'>Notify Me - Join Waitlist</button>
18
+ </div>
19
+
20
+ <!-- Mock product so the demo popup can populate (Shopify provides this on the storefront). -->
21
+ <script>
22
+ window.currentProduct = {
23
+ id: 123456789,
24
+ title: 'Sample Product',
25
+ variants: [
26
+ { id: 1, title: 'Small' },
27
+ { id: 2, title: 'Medium' },
28
+ { id: 3, title: 'Large' },
29
+ ],
30
+ };
31
+ </script>
32
+ <script src="index.js"></script>
19
33
  </body>
20
- <script src="/index.js"></script>
21
34
  </html>
package/index.js CHANGED
@@ -3,6 +3,38 @@ const ACCESS_TOKEN =
3
3
  'Bearer eyJhbGciOiJSUzI1NiIsInR5cCIgOiAiSldUIiwia2lkIiA6ICJRWlJkS3JabXJmMEk3WkhXRUtqNWRLTEhQanFubWJFeV9iNmpSbHdya1drIn0.eyJleHAiOjE3MjMwMzI3NzIsImlhdCI6MTcyMzAyOTE3MiwianRpIjoiNDJiNGYwM2QtMDNiNS00NmZlLTk5YmItZDQ2NTdhNjk5NGNiIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLmF1LWF3cy50aGV3aXNobGlzdC5pby9hdXRoL3JlYWxtcy90d2NNYWluIiwiYXVkIjoiYWNjb3VudCIsInN1YiI6IjI2YTIyNTAyLWRhNzQtNDhkMC1iZWFiLTgzY2E0YTlmMDdlOSIsInR5cCI6IkJlYXJlciIsImF6cCI6InR3Yy1wb3MtY2xpZW50Iiwic2Vzc2lvbl9zdGF0ZSI6IjQxYWQwMDc4LWVmYTItNGVjMi1hM2I2LTIzYjBkNDM3YjEzOCIsImFjciI6IjEiLCJhbGxvd2VkLW9yaWdpbnMiOlsiaHR0cHM6Ly9sb2NhbGhvc3QiXSwicmVhbG1fYWNjZXNzIjp7InJvbGVzIjpbInR3Yy1wb3MtdXNlciIsIm9mZmxpbmVfYWNjZXNzIiwidHdjLXN0b3JlLW93bmVyIiwidW1hX2F1dGhvcml6YXRpb24iXX0sInJlc291cmNlX2FjY2VzcyI6eyJhY2NvdW50Ijp7InJvbGVzIjpbIm1hbmFnZS1hY2NvdW50IiwibWFuYWdlLWFjY291bnQtbGlua3MiLCJ2aWV3LXByb2ZpbGUiXX19LCJzY29wZSI6InRlbmFudGlkIHN0b3JlIHByb2ZpbGUgZW1haWwiLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsInRlbmFudGlkIjoidmlrdG9yaWEtd29vZHMiLCJuYW1lIjoiTWF0dCBIYW1wc2hpcmUiLCJwcmVmZXJyZWRfdXNlcm5hbWUiOiJtYXR0QHRoZXdpc2hsaXN0LmlvIiwic3RvcmUiOiIyMDUiLCJnaXZlbl9uYW1lIjoiTWF0dCIsImZhbWlseV9uYW1lIjoiSGFtcHNoaXJlIiwiZW1haWwiOiJtYXR0QHRoZXdpc2hsaXN0LmlvIn0.iZkMgwH74njjXUWvImkJomHYr91Lr8ZGrZGwslEGcV3vbNuoNc5CocvNWW476o-LoSh-LsKf-MLiYN1XvOuPDF3fGoGCEbMh6_M0RJcrhVWogkj81fx4ukvDPCFIjgoDCV9WIuehV9dsSWa7E0irZeE6MUVhLwRIaTzKtxgzUUKrAqBtI_HKpyo8TUGQBiYlrc85QFUyuoKbKg-QaRn_SObRLDB8ooIBJvIlgklXQt1ZYBM2HUOc5L1bAQwfzcrWEvl6eYiQHXCSPqS0rPGoaGC6v5ydBo9VMxtVHGladDHLrO3Gt2BnIGMBoYrKTAmt7j0KABwPyB3CmAIwj_pOBQ'; // Replace `ACCESS_TOKEN` with your actual access token to authenticate API requests.
4
4
  const TENANT_ID = 'victoria-woods'; // Replace `TENANT_ID` with your actual tenant ID.
5
5
 
6
+ // Session-cached access token obtained from the Shopify App Proxy (proxy auth mode).
7
+ let cachedProxyToken = null;
8
+
9
+ // Fetch a tenant-scoped access token from the Shopify App Proxy.
10
+ // The path is relative/same-origin, so Shopify's App Proxy layer appends and
11
+ // signs `shop`, `logged_in_customer_id`, `timestamp`, and `signature`.
12
+ // Returns the access token string, or null on failure (e.g. customer not logged in).
13
+ async function getProxyAccessToken() {
14
+ if (cachedProxyToken) return cachedProxyToken;
15
+ try {
16
+ const response = await fetch('/apps/twc-sdk/auth/token', {
17
+ method: 'GET',
18
+ headers: { Accept: 'application/json' },
19
+ });
20
+ const body = await response.json().catch(() => null);
21
+ if (
22
+ response.ok &&
23
+ body &&
24
+ body.success &&
25
+ body.data &&
26
+ body.data.access_token
27
+ ) {
28
+ cachedProxyToken = body.data.access_token;
29
+ return cachedProxyToken;
30
+ }
31
+ return null;
32
+ } catch (error) {
33
+ console.error('Error fetching proxy access token:', error);
34
+ return null;
35
+ }
36
+ }
37
+
6
38
  const COUNTRY_CODES = [
7
39
  { code: 'AF', name: 'Afghanistan' }, { code: 'AX', name: 'Åland Islands' },
8
40
  { code: 'AL', name: 'Albania' }, { code: 'DZ', name: 'Algeria' },
@@ -158,130 +190,200 @@ document.addEventListener('DOMContentLoaded', function () {
158
190
  const wrapper = document.getElementById('notification-widget');
159
191
  if (!wrapper) return;
160
192
 
161
- // Define and apply styles for the popup and button
193
+ // Define and apply styles. Everything is namespaced under #twc-nm-overlay and
194
+ // uses `font: inherit` so the widget blends into the host store's theme
195
+ // without colliding with (or being broken by) the store's own CSS.
162
196
  const styles = `
163
197
  .notification-btn {
164
- background-color: #fff;
165
- border: 1px solid black;
166
- color: black;
198
+ display: inline-block;
199
+ width: 100%;
167
200
  padding: 12px 24px;
201
+ background: #fff;
202
+ border: 1px solid #111827;
203
+ border-radius: 8px;
204
+ color: #111827;
205
+ font: inherit;
168
206
  text-align: center;
169
- width: 100%;
170
- cursor: pointer;
171
207
  text-transform: uppercase;
208
+ cursor: pointer;
172
209
  }
173
- #popup-body.overlay {
210
+ .notification-btn:hover {
211
+ background: #111827;
212
+ color: #fff;
213
+ }
214
+ #twc-nm-overlay {
174
215
  position: fixed;
175
- z-index: 9998;
176
- top: 0;
177
- bottom: 0;
178
- left: 0;
179
- right: 0;
180
- background: rgba(0, 0, 0, 0.7);
181
- transition: opacity 200ms;
182
- visibility: hidden;
216
+ inset: 0;
217
+ z-index: 99999;
218
+ display: flex;
219
+ align-items: center;
220
+ justify-content: center;
221
+ padding: 16px;
222
+ background: rgba(17, 17, 17, 0.55);
183
223
  opacity: 0;
224
+ visibility: hidden;
225
+ transition: opacity 200ms ease;
184
226
  }
185
- #popup-wrapper {
186
- margin: 70px auto;
187
- z-index: 9999;
188
- padding: 15px;
189
- background: #fff;
190
- border-radius: 5px;
191
- max-width: 360px;
227
+ #twc-nm-overlay.is-open {
228
+ opacity: 1;
229
+ visibility: visible;
230
+ }
231
+ #twc-nm-overlay * {
232
+ box-sizing: border-box;
233
+ }
234
+ #twc-nm-overlay .twc-nm-card {
192
235
  position: relative;
236
+ width: 100%;
237
+ max-width: 400px;
238
+ max-height: calc(100vh - 32px);
239
+ overflow-y: auto;
240
+ padding: 24px;
241
+ background: #fff;
242
+ color: #111827;
243
+ border-radius: 12px;
244
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.18);
245
+ font: inherit;
246
+ transform: translateY(8px);
247
+ transition: transform 200ms ease;
193
248
  }
194
- #popup-close {
249
+ #twc-nm-overlay.is-open .twc-nm-card {
250
+ transform: translateY(0);
251
+ }
252
+ #twc-nm-overlay .twc-nm-close {
195
253
  position: absolute;
196
- top: 5px;
197
- right: 15px;
198
- transition: all 200ms;
199
- font-size: 30px;
200
- font-weight: bold;
201
- text-decoration: none;
202
- color: #333;
254
+ top: 12px;
255
+ right: 12px;
256
+ width: 32px;
257
+ height: 32px;
258
+ display: flex;
259
+ align-items: center;
260
+ justify-content: center;
261
+ padding: 0;
262
+ border: none;
263
+ background: transparent;
264
+ border-radius: 6px;
265
+ font-size: 24px;
266
+ line-height: 1;
267
+ color: #6b7280;
203
268
  cursor: pointer;
204
269
  }
205
- #popup-close:hover {
206
- color: #d80606;
270
+ #twc-nm-overlay .twc-nm-close:hover {
271
+ background: #f3f4f6;
272
+ color: #111827;
207
273
  }
208
- #popup-title {
209
- margin: 0;
210
- font-size: 20px;
211
- max-width: 350px;
212
- text-transform: uppercase;
274
+ #twc-nm-overlay .twc-nm-title {
275
+ margin: 0 32px 4px 0;
276
+ font-size: 18px;
277
+ font-weight: 600;
213
278
  }
214
- #popup-text {
215
- margin: 10px 0;
279
+ #twc-nm-overlay .twc-nm-text {
280
+ margin: 0 0 16px;
216
281
  font-size: 14px;
282
+ line-height: 1.5;
283
+ color: #6b7280;
217
284
  }
218
- #popup-form {
285
+ #twc-nm-overlay .twc-nm-form {
219
286
  display: flex;
220
287
  flex-direction: column;
221
- gap: 10px;
288
+ gap: 14px;
222
289
  }
223
- #popup-form input {
224
- padding: 0.475em 1em;
225
- border: 1px solid #caced1;
226
- border-radius: 0.25rem;
227
- font-size: 1.15rem;
228
- max-width: 100%;
290
+ #twc-nm-overlay .twc-nm-field {
291
+ display: flex;
292
+ flex-direction: column;
293
+ gap: 6px;
229
294
  }
230
- #popup-form input::placeholder {
231
- font-size: 1rem;
295
+ #twc-nm-overlay .twc-nm-label {
296
+ font-size: 13px;
297
+ font-weight: 500;
298
+ color: #374151;
232
299
  }
233
- #popup-form button {
234
- font-size: 0.85rem;
235
- padding: 0.975em 1em;
236
- font-weight: 600;
237
- border: none;
238
- text-transform: uppercase;
239
- background: #aca475;
240
- color: #fff;
241
- cursor: pointer;
300
+ #twc-nm-overlay .twc-nm-input,
301
+ #twc-nm-overlay .twc-nm-select {
302
+ width: 100%;
303
+ padding: 10px 12px;
304
+ font-size: 14px;
305
+ font-family: inherit;
306
+ color: #111827;
307
+ background: #fff;
308
+ border: 1px solid #e5e7eb;
309
+ border-radius: 8px;
242
310
  }
243
- .checkbox-item {
244
- display: flex;
245
- align-items: center;
246
- gap: 8px;
247
- }
248
- .checkbox-item input[type="checkbox"] {
249
- width: auto;
250
- margin: 0;
251
- }
252
- .custom-select {
253
- position: relative;
311
+ #twc-nm-overlay .twc-nm-input:focus,
312
+ #twc-nm-overlay .twc-nm-select:focus {
313
+ outline: none;
314
+ border-color: #111827;
315
+ box-shadow: 0 0 0 3px rgba(17, 24, 39, 0.08);
254
316
  }
255
- .custom-select select {
317
+ #twc-nm-overlay .twc-nm-select {
256
318
  appearance: none;
319
+ -webkit-appearance: none;
320
+ padding-right: 36px;
321
+ cursor: pointer;
322
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M2 4l4 4 4-4' stroke='%236b7280' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
323
+ background-repeat: no-repeat;
324
+ background-position: right 12px center;
325
+ }
326
+ #twc-nm-overlay .twc-nm-checks {
327
+ display: flex;
328
+ flex-direction: column;
329
+ gap: 10px;
330
+ }
331
+ #twc-nm-overlay .twc-nm-check {
332
+ display: flex;
333
+ align-items: center;
334
+ gap: 8px;
335
+ font-size: 13px;
336
+ color: #374151;
337
+ cursor: pointer;
338
+ }
339
+ #twc-nm-overlay .twc-nm-check input {
340
+ width: 16px;
341
+ height: 16px;
342
+ margin: 0;
343
+ accent-color: #111827;
344
+ }
345
+ #twc-nm-overlay .twc-nm-status {
346
+ font-size: 13px;
347
+ line-height: 1.5;
348
+ padding: 10px 12px;
349
+ border-radius: 8px;
350
+ }
351
+ #twc-nm-overlay .twc-nm-status--error {
352
+ color: #b91c1c;
353
+ background: #fef2f2;
354
+ }
355
+ #twc-nm-overlay .twc-nm-status--success {
356
+ color: #15803d;
357
+ background: #f0fdf4;
358
+ }
359
+ #twc-nm-overlay .twc-nm-submit {
257
360
  width: 100%;
258
- font-size: 1.15rem;
259
- padding: 0.475em 1em;
260
- background-color: #fff;
261
- border: 1px solid #caced1;
262
- border-radius: 0.25rem;
263
- color: #000;
361
+ padding: 12px 16px;
362
+ font-size: 14px;
363
+ font-weight: 600;
364
+ font-family: inherit;
365
+ color: #fff;
366
+ background: #111827;
367
+ border: none;
368
+ border-radius: 8px;
264
369
  cursor: pointer;
370
+ transition: background 150ms ease;
265
371
  }
266
- .custom-select::before,
267
- .custom-select::after {
268
- --size: 0.3rem;
269
- content: "";
270
- position: absolute;
271
- right: 1rem;
272
- pointer-events: none;
372
+ #twc-nm-overlay .twc-nm-submit:hover {
373
+ background: #374151;
273
374
  }
274
- .custom-select::before {
275
- border-left: var(--size) solid transparent;
276
- border-right: var(--size) solid transparent;
277
- border-bottom: var(--size) solid black;
278
- top: 40%;
375
+ #twc-nm-overlay .twc-nm-submit:disabled {
376
+ opacity: 0.6;
377
+ cursor: default;
279
378
  }
280
- .custom-select::after {
281
- border-left: var(--size) solid transparent;
282
- border-right: var(--size) solid transparent;
283
- border-top: var(--size) solid black;
284
- top: 55%;
379
+ @media (prefers-reduced-motion: reduce) {
380
+ #twc-nm-overlay,
381
+ #twc-nm-overlay .twc-nm-card {
382
+ transition: none;
383
+ }
384
+ #twc-nm-overlay .twc-nm-card {
385
+ transform: none;
386
+ }
285
387
  }
286
388
  `;
287
389
 
@@ -293,17 +395,18 @@ document.addEventListener('DOMContentLoaded', function () {
293
395
  // Create and append the overlay for the popup
294
396
  const overlay = document.createElement('div');
295
397
  overlay.innerHTML = `
296
- <div id="popup-body" class="overlay">
297
- <div id="popup-wrapper">
298
- <h3 id="popup-title"></h3>
299
- <span id="popup-close">&times;</span>
300
- <div id="popup-text"></div>
301
- <form id="popup-form">
302
- <div class="custom-select">
303
- <select name="select-size" required>
398
+ <div id="twc-nm-overlay" role="dialog" aria-modal="true" aria-labelledby="popup-title">
399
+ <div class="twc-nm-card">
400
+ <button type="button" id="popup-close" class="twc-nm-close" aria-label="Close">&times;</button>
401
+ <h2 id="popup-title" class="twc-nm-title"></h2>
402
+ <p id="popup-text" class="twc-nm-text"></p>
403
+ <form id="popup-form" class="twc-nm-form">
404
+ <div class="twc-nm-field">
405
+ <label class="twc-nm-label" for="twc-nm-size">Size</label>
406
+ <select class="twc-nm-select" id="twc-nm-size" name="select-size" required>
407
+ <option value="" disabled selected>Select a size</option>
304
408
  </select>
305
409
  </div>
306
- <input name="email" placeholder="Email" type="email" required />
307
410
  </form>
308
411
  </div>
309
412
  </div>
@@ -311,16 +414,16 @@ document.addEventListener('DOMContentLoaded', function () {
311
414
 
312
415
  wrapper.appendChild(overlay);
313
416
 
314
- const popupBody = document.getElementById('popup-body');
417
+ const overlayEl = document.getElementById('twc-nm-overlay');
315
418
  const popupClose = document.getElementById('popup-close');
316
419
  const popupOpenButton = document.getElementById('popup-open');
317
420
  const popupTitle = document.getElementById('popup-title');
318
421
  const popupText = document.getElementById('popup-text');
319
422
  const sizeSelect = document.querySelector("select[name='select-size']");
320
- const form = overlay.querySelector('#popup-form');
423
+ const form = document.getElementById('popup-form');
321
424
 
322
425
  if (
323
- !popupBody ||
426
+ !overlayEl ||
324
427
  !popupClose ||
325
428
  !popupOpenButton ||
326
429
  !popupTitle ||
@@ -333,10 +436,14 @@ document.addEventListener('DOMContentLoaded', function () {
333
436
  popupOpenButton.getAttribute('data-fields') || '["email"]',
334
437
  );
335
438
  const type = popupOpenButton.getAttribute('data-type') || 'notify-me';
439
+ // Auth mode: 'token' (default, hardcoded ACCESS_TOKEN) or 'proxy' (Shopify App Proxy).
440
+ const authMode = popupOpenButton.getAttribute('data-auth') || 'token';
441
+ // Tenant for X-Twc-Tenant header; used in both auth modes.
442
+ const tenant = popupOpenButton.getAttribute('data-tenant') || TENANT_ID;
336
443
  const typeConfig = {
337
444
  'notify-me': {
338
445
  text: 'Register to receive a notification as soon as this item is back in stock',
339
- buttonText: 'Notify me',
446
+ buttonText: 'Notify Me',
340
447
  },
341
448
  'coming-soon': {
342
449
  text: 'Register your interest to hear more about this item',
@@ -359,145 +466,228 @@ document.addEventListener('DOMContentLoaded', function () {
359
466
  `<option value="${c.code}"${c.code === countryCtx.countryCode ? ' selected' : ''}>${c.name}</option>`,
360
467
  ).join('');
361
468
 
469
+ // Wrap an input/select in a labeled field row.
470
+ const field = (id, label, control) =>
471
+ `<div class="twc-nm-field"><label class="twc-nm-label" for="${id}">${label}</label>${control}</div>`;
472
+
362
473
  const fieldMap = {
363
- email: `<input name="email" placeholder="Email" type="email" required />`,
364
- mobile: `<input name="mobile" placeholder="Mobile" type="tel" required />`,
365
- firstName: `<input name="firstName" placeholder="First name" type="text" required />`,
366
- lastName: `<input name="lastName" placeholder="Last name" type="text" required />`,
367
- countryCode: `<div class="custom-select"><select name="countryCode"><option value="">Select country</option>${countryOptions}</select></div>`,
368
- provinceCode: `<input name="provinceCode" placeholder="State / Province code" type="text" value="${countryCtx.provinceCode || ''}" />`,
474
+ email: field(
475
+ 'twc-nm-email',
476
+ 'Email',
477
+ `<input class="twc-nm-input" id="twc-nm-email" name="email" type="email" placeholder="you@example.com" required />`,
478
+ ),
479
+ mobile: field(
480
+ 'twc-nm-mobile',
481
+ 'Mobile',
482
+ `<input class="twc-nm-input" id="twc-nm-mobile" name="mobile" type="tel" placeholder="Mobile number" required />`,
483
+ ),
484
+ firstName: field(
485
+ 'twc-nm-firstname',
486
+ 'First name',
487
+ `<input class="twc-nm-input" id="twc-nm-firstname" name="firstName" type="text" placeholder="First name" required />`,
488
+ ),
489
+ lastName: field(
490
+ 'twc-nm-lastname',
491
+ 'Last name',
492
+ `<input class="twc-nm-input" id="twc-nm-lastname" name="lastName" type="text" placeholder="Last name" required />`,
493
+ ),
494
+ countryCode: field(
495
+ 'twc-nm-country',
496
+ 'Country',
497
+ `<select class="twc-nm-select" id="twc-nm-country" name="countryCode"><option value="">Select country</option>${countryOptions}</select>`,
498
+ ),
499
+ provinceCode: field(
500
+ 'twc-nm-province',
501
+ 'State / Province',
502
+ `<input class="twc-nm-input" id="twc-nm-province" name="provinceCode" type="text" placeholder="State / province code" value="${countryCtx.provinceCode || ''}" />`,
503
+ ),
369
504
  };
370
505
 
371
506
  // Add fields to the form based on the parsed fields
372
- fields.forEach((field) => {
373
- if (fieldMap[field]) {
374
- form.insertAdjacentHTML('beforeend', fieldMap[field]);
507
+ fields.forEach((name) => {
508
+ if (fieldMap[name]) {
509
+ form.insertAdjacentHTML('beforeend', fieldMap[name]);
375
510
  }
376
511
  });
377
512
 
378
- form.insertAdjacentHTML('beforeend', `<div id="checkbox-wrapper"></div>`);
379
- const checkboxWrapper = document.getElementById('checkbox-wrapper');
380
- checkboxWrapper.insertAdjacentHTML(
513
+ form.insertAdjacentHTML(
381
514
  'beforeend',
382
- `<div class="checkbox-item"><input type="checkbox" name="mailList" id="mailList" /><label for="mailList">Subscribe to our mailing list</label></div>`,
515
+ `<div class="twc-nm-checks">
516
+ <label class="twc-nm-check"><input type="checkbox" name="mailList" /> Email me store updates</label>
517
+ <label class="twc-nm-check"><input type="checkbox" name="mailListSms" /> Text me store updates</label>
518
+ </div>`,
383
519
  );
384
- checkboxWrapper.insertAdjacentHTML(
520
+ form.insertAdjacentHTML(
385
521
  'beforeend',
386
- `<div class="checkbox-item"><input type="checkbox" name="mailListSms" id="mailListSms" /><label for="mailListSms">Subscribe to our mailing list via SMS</label></div>`,
522
+ `<div id="popup-status" class="twc-nm-status" role="status" aria-live="polite" hidden></div>`,
387
523
  );
388
524
  form.insertAdjacentHTML(
389
525
  'beforeend',
390
- `<button type="submit">${typeConfig[type].buttonText}</button>`,
526
+ `<button type="submit" class="twc-nm-submit">${typeConfig[type].buttonText}</button>`,
391
527
  );
392
528
 
393
- // Show/hide the popup
529
+ const popupStatus = document.getElementById('popup-status');
530
+ const submitBtn = form.querySelector('.twc-nm-submit');
531
+
532
+ // Inline status messaging (replaces alert()).
533
+ function setStatus(kind, message) {
534
+ popupStatus.textContent = message;
535
+ popupStatus.className = `twc-nm-status twc-nm-status--${kind}`;
536
+ popupStatus.hidden = false;
537
+ }
538
+ function clearStatus() {
539
+ popupStatus.hidden = true;
540
+ popupStatus.textContent = '';
541
+ }
542
+
543
+ // Open / close the popup.
544
+ let variantsPopulated = false;
545
+ let lastFocused = null;
546
+
547
+ function onKeydown(event) {
548
+ if (event.key === 'Escape') closePopup();
549
+ }
550
+
551
+ function openPopup() {
552
+ lastFocused = document.activeElement;
553
+ overlayEl.classList.add('is-open');
554
+ document.body.style.overflow = 'hidden';
555
+ document.addEventListener('keydown', onKeydown);
556
+ }
557
+
558
+ function closePopup() {
559
+ overlayEl.classList.remove('is-open');
560
+ document.body.style.overflow = '';
561
+ document.removeEventListener('keydown', onKeydown);
562
+ clearStatus();
563
+ if (lastFocused && typeof lastFocused.focus === 'function') {
564
+ lastFocused.focus();
565
+ }
566
+ }
567
+
394
568
  function showPopup() {
569
+ openPopup();
570
+
395
571
  if (!productData) {
396
- alert('Product data not found');
572
+ form.hidden = true;
573
+ setStatus('error', 'Product information is unavailable. Please refresh the page.');
397
574
  return;
398
575
  }
399
- // Set the popup title and populate the size dropdown
400
- popupTitle.innerHTML = productData.title;
401
- productData.variants.forEach((variant) => {
402
- const option = document.createElement('option');
403
- option.value = variant.title;
404
- option.textContent = variant.title;
405
- sizeSelect.appendChild(option);
406
- });
407
- popupBody.style.visibility = 'visible';
408
- popupBody.style.opacity = 1;
409
- }
410
576
 
411
- function hidePopup() {
412
- popupBody.style.visibility = 'hidden';
413
- popupBody.style.opacity = 0;
577
+ popupTitle.textContent = productData.title;
578
+
579
+ if (!variantsPopulated) {
580
+ (productData.variants || []).forEach((variant) => {
581
+ const option = document.createElement('option');
582
+ option.value = variant.title;
583
+ option.textContent = variant.title;
584
+ sizeSelect.appendChild(option);
585
+ });
586
+ variantsPopulated = true;
587
+ }
588
+
589
+ const firstControl = form.querySelector('select, input');
590
+ if (firstControl) firstControl.focus();
414
591
  }
415
592
 
416
593
  popupOpenButton.addEventListener('click', showPopup);
417
- popupClose.addEventListener('click', hidePopup);
594
+ popupClose.addEventListener('click', closePopup);
595
+ // Close when clicking the backdrop (but not the card).
596
+ overlayEl.addEventListener('click', function (event) {
597
+ if (event.target === overlayEl) closePopup();
598
+ });
418
599
 
419
600
  // Form submission handler
420
- if (form) {
421
- form.addEventListener('submit', function (event) {
422
- event.preventDefault();
423
-
424
- const selectedSize = sizeSelect.value;
425
- const selectedVariant = productData.variants.find(
426
- (variant) => variant.title === selectedSize,
427
- );
428
-
429
- if (selectedVariant) {
430
- const formData = {
431
- variantRef: selectedVariant.id,
432
- email: form.querySelector("input[name='email']").value,
433
- subscribe: form.querySelector("input[name='mailList']").checked,
434
- subscribeSms: form.querySelector("input[name='mailListSms']").checked,
435
- };
436
-
437
- if (type === 'coming-soon') {
438
- formData.comingSoon = true;
439
- // formData.productRef = productData.id;
440
- } else {
441
- formData.notifyMe = true;
442
- }
443
- if (fields.includes('firstName')) {
444
- formData.firstName = form.querySelector(
445
- "input[name='firstName']",
446
- ).value;
447
- }
448
- if (fields.includes('lastName')) {
449
- formData.lastName = form.querySelector(
450
- "input[name='lastName']",
451
- ).value;
452
- }
453
- if (fields.includes('mobile')) {
454
- formData.mobile = form.querySelector("input[name='mobile']").value;
455
- // formData.phone = form.querySelector("input[name='mobile']").value;
456
- }
601
+ form.addEventListener('submit', async function (event) {
602
+ event.preventDefault();
603
+
604
+ const selectedVariant = (productData?.variants || []).find(
605
+ (variant) => variant.title === sizeSelect.value,
606
+ );
607
+ if (!selectedVariant) {
608
+ setStatus('error', 'Please select a size.');
609
+ return;
610
+ }
457
611
 
458
- // Country / province / market — manual fields take precedence over auto-detected values
459
- if (fields.includes('countryCode')) {
460
- const selected = form.querySelector("select[name='countryCode']").value;
461
- if (selected) formData.countryCode = selected;
462
- } else if (countryCtx.countryCode) {
463
- formData.countryCode = countryCtx.countryCode;
464
- }
612
+ const formData = {
613
+ variantRef: selectedVariant.id,
614
+ email: form.querySelector("input[name='email']").value,
615
+ subscribe: form.querySelector("input[name='mailList']").checked,
616
+ subscribeSms: form.querySelector("input[name='mailListSms']").checked,
617
+ };
618
+
619
+ if (type === 'coming-soon') {
620
+ formData.comingSoon = true;
621
+ } else {
622
+ formData.notifyMe = true;
623
+ }
624
+ if (fields.includes('firstName')) {
625
+ formData.firstName = form.querySelector("input[name='firstName']").value;
626
+ }
627
+ if (fields.includes('lastName')) {
628
+ formData.lastName = form.querySelector("input[name='lastName']").value;
629
+ }
630
+ if (fields.includes('mobile')) {
631
+ formData.mobile = form.querySelector("input[name='mobile']").value;
632
+ }
633
+
634
+ // Country / province / market — manual fields take precedence over auto-detected values
635
+ if (fields.includes('countryCode')) {
636
+ const selected = form.querySelector("select[name='countryCode']").value;
637
+ if (selected) formData.countryCode = selected;
638
+ } else if (countryCtx.countryCode) {
639
+ formData.countryCode = countryCtx.countryCode;
640
+ }
641
+
642
+ if (fields.includes('provinceCode')) {
643
+ const val = form.querySelector("input[name='provinceCode']").value;
644
+ if (val) formData.provinceCode = val;
645
+ } else if (countryCtx.provinceCode) {
646
+ formData.provinceCode = countryCtx.provinceCode;
647
+ }
465
648
 
466
- if (fields.includes('provinceCode')) {
467
- const val = form.querySelector("input[name='provinceCode']").value;
468
- if (val) formData.provinceCode = val;
469
- } else if (countryCtx.provinceCode) {
470
- formData.provinceCode = countryCtx.provinceCode;
649
+ if (resolvedMarketId) formData.marketId = resolvedMarketId;
650
+
651
+ clearStatus();
652
+ const originalButtonText = submitBtn.textContent;
653
+ submitBtn.disabled = true;
654
+ submitBtn.textContent = 'Sending…';
655
+
656
+ try {
657
+ // Resolve the Authorization token based on auth mode.
658
+ let authToken = ACCESS_TOKEN;
659
+ if (authMode === 'proxy') {
660
+ const proxyToken = await getProxyAccessToken();
661
+ if (!proxyToken) {
662
+ setStatus('error', 'Please log in to your account to continue.');
663
+ return;
471
664
  }
665
+ authToken = `Bearer ${proxyToken}`;
666
+ }
472
667
 
473
- if (resolvedMarketId) formData.marketId = resolvedMarketId;
474
-
475
- let url = `https://api.au-sandbox.thewishlist.io/services/wsservice/api/wishlist/items/customerInterest`;
476
-
477
- fetch(url, {
478
- method: 'POST',
479
- headers: {
480
- 'Content-Type': 'application/json',
481
- Authorization: ACCESS_TOKEN,
482
- 'X-Twc-Tenant': TENANT_ID,
483
- },
484
- body: JSON.stringify(formData),
485
- })
486
- .then((response) => {
487
- if (response.ok) {
488
- alert('Form submitted');
489
- hidePopup();
490
- } else {
491
- alert('Form submission failed');
492
- }
493
- })
494
- .catch((error) => {
495
- console.error('Error submitting form:', error);
496
- alert('Form submission failed');
497
- });
668
+ const url = `https://api.au-sandbox.thewishlist.io/services/wsservice/api/wishlist/items/customerInterest`;
669
+ const response = await fetch(url, {
670
+ method: 'POST',
671
+ headers: {
672
+ 'Content-Type': 'application/json',
673
+ Authorization: authToken,
674
+ 'X-Twc-Tenant': tenant,
675
+ },
676
+ body: JSON.stringify(formData),
677
+ });
678
+
679
+ if (response.ok) {
680
+ setStatus('success', "You're on the list. We'll be in touch.");
681
+ setTimeout(closePopup, 1500);
498
682
  } else {
499
- alert('Selected variant not found');
683
+ setStatus('error', 'Something went wrong. Please try again.');
500
684
  }
501
- });
502
- }
685
+ } catch (error) {
686
+ console.error('Error submitting form:', error);
687
+ setStatus('error', 'Something went wrong. Please try again.');
688
+ } finally {
689
+ submitBtn.disabled = false;
690
+ submitBtn.textContent = originalButtonText;
691
+ }
692
+ });
503
693
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "notify-me-wl",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1"