studio-engine 0.96.2 → 0.97.0.rc1

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +183 -0
  3. data/app/assets/tailwind/studio_engine/engine-motion.css +57 -0
  4. data/app/assets/tailwind/studio_engine/engine.css +8 -0
  5. data/app/javascript/studio/alpine_shims.js +23 -0
  6. data/app/javascript/studio/application.js +9 -21
  7. data/app/javascript/studio/board.js +593 -0
  8. data/app/javascript/studio/booking.js +176 -0
  9. data/app/javascript/studio/controllers/board_controller.js +41 -0
  10. data/app/javascript/studio/controllers/booking_controller.js +24 -0
  11. data/app/javascript/studio/controllers/email_banner_scale_controller.js +22 -0
  12. data/app/javascript/studio/controllers/footer_map_controller.js +22 -0
  13. data/app/javascript/studio/controllers/geo_settings_controller.js +24 -0
  14. data/app/javascript/studio/controllers/hold_button_controller.js +64 -0
  15. data/app/javascript/studio/controllers/link_preview_card_controller.js +25 -0
  16. data/app/javascript/studio/controllers/link_sidebar_controller.js +18 -0
  17. data/app/javascript/studio/controllers/survey_controller.js +18 -0
  18. data/app/javascript/studio/controllers/toast_controller.js +26 -0
  19. data/app/javascript/studio/email_banner.js +277 -0
  20. data/app/javascript/studio/email_banner_scale.js +60 -0
  21. data/app/javascript/studio/footer_map.js +143 -0
  22. data/app/javascript/studio/geo_settings.js +105 -0
  23. data/app/javascript/studio/hold_button.js +416 -0
  24. data/app/javascript/studio/hold_button_hooks.js +162 -0
  25. data/app/javascript/studio/lazy_controllers.js +138 -0
  26. data/app/javascript/studio/leveling_activity.js +252 -0
  27. data/app/javascript/studio/link_preview_card.js +34 -0
  28. data/app/javascript/studio/link_sidebar.js +157 -0
  29. data/app/javascript/studio/stimulus.js +72 -0
  30. data/app/javascript/studio/survey.js +316 -0
  31. data/app/javascript/studio/theme_editor.js +197 -0
  32. data/app/javascript/studio/toast.js +270 -0
  33. data/app/views/components/_link_sidebar.html.erb +5 -100
  34. data/app/views/components/_link_sidebar_trigger.html.erb +2 -2
  35. data/app/views/layouts/studio/_flash.html.erb +13 -118
  36. data/app/views/layouts/studio/_head.html.erb +30 -14
  37. data/app/views/studio/_board_assets.html.erb +9 -452
  38. data/app/views/studio/_hold_button.html.erb +38 -445
  39. data/app/views/studio/_hold_button_guard.html.erb +172 -0
  40. data/app/views/studio/_leveling_activity_assets.html.erb +9 -283
  41. data/app/views/studio/board/_board.html.erb +16 -5
  42. data/app/views/studio/booking/_assets.html.erb +5 -139
  43. data/app/views/studio/booking/_frame.html.erb +3 -3
  44. data/app/views/studio/booking/_popup.html.erb +4 -4
  45. data/app/views/studio/emails/_banner_editor.html.erb +4 -152
  46. data/app/views/studio/emails/_banner_scale.html.erb +5 -56
  47. data/app/views/studio/emails/index.html.erb +0 -1
  48. data/app/views/studio/geo_settings/edit.html.erb +1 -97
  49. data/app/views/studio/site_footer/_map.html.erb +7 -5
  50. data/app/views/studio/site_footer/_map_assets.html.erb +5 -129
  51. data/app/views/studio/site_identities/edit.html.erb +1 -24
  52. data/app/views/studio/surveys/show.html.erb +4 -5
  53. data/app/views/style/_tasks.html.erb +3 -8
  54. data/app/views/style/_theme.html.erb +2 -52
  55. data/app/views/theme_settings/edit.html.erb +3 -82
  56. data/config/importmap.rb +6 -0
  57. data/docs/BOOKING.md +6 -5
  58. data/docs/SITE_FOOTER.md +16 -12
  59. data/lib/studio/version.rb +1 -1
  60. metadata +28 -3
  61. data/app/views/studio/emails/_recipient_repaint.html.erb +0 -122
  62. data/app/views/studio/surveys/_script.html.erb +0 -309
@@ -4,13 +4,27 @@
4
4
  A press-and-hold CTA for an action a host does not want taken by accident.
5
5
  Ported from turf-monster, where it confirms a contest entry.
6
6
 
7
- Per-instance config travels with the button as data-* attributes
8
- and is evaluated against the surrounding x-data scope at runtime
9
- via Alpine.evaluate (with `d` aliased to the scope's $data). This
10
- lets a single page host multiple hold buttons with different
11
- callbacks — the previous baked-in-ERB approach put callbacks on
12
- a window-level handler that the FIRST rendered button populated,
13
- silently forcing every later button to inherit those callbacks.
7
+ The behaviour is studio/hold_button, on the hold-button controller
8
+ (studio/controllers/hold_button_controller), which arrives with the page. When
9
+ it does not (a module request failed at page load), the guard in the head
10
+ (studio/_hold_button_guard) marks the stack, and the button reads as
11
+ unavailable beside "This button could not load. Reload the page." A page
12
+ answers a hold through the events the button dispatches, which bubble:
13
+
14
+ hold-button:guard at the press; preventDefault() refuses the hold
15
+ hold-button:start the hold began
16
+ hold-button:validate at validate_at; $event.detail.waitUntil(answer),
17
+ a false or failed answer aborts the hold
18
+ hold-button:early at early_action_at; preventDefault() takes the
19
+ action over and the hold never completes
20
+ hold-button:success at the full duration; preventDefault() leaves the
21
+ button's state to the listener
22
+
23
+ The JS-expression locals below (guard, on_success, validate, early_action,
24
+ early_action_guard, on_hold_start) still work: they travel as data-*
25
+ attributes and are evaluated against the surrounding x-data scope, with `d`
26
+ and `data` aliased to its data. They stay while a consumer passes them;
27
+ write new call sites against the events.
14
28
 
15
29
  Locals:
16
30
  default_text - Button label in idle state (default: "Hold to Confirm")
@@ -21,9 +35,12 @@
21
35
  guard - JS expression that must be truthy to allow hold (default: none = always allow)
22
36
  on_success - JS expression executed on successful hold (default: none)
23
37
  validate - JS expression returning Promise<boolean>, called at validate_at ms (default: none)
24
- validate_at - ms delay before running validation (default: 750)
38
+ validate_at - ms into the hold at which validation runs: the validate
39
+ string and the hold-button:validate event (default: 750)
25
40
  early_action - JS expression executed early (for Web3 users) (default: none)
26
- early_action_at - ms delay before firing early action (default: 1500)
41
+ early_action_at - ms into the hold at which the early action runs: the
42
+ early_action string and the hold-button:early event
43
+ (default: 1500)
27
44
  early_action_guard - JS expression that must be truthy for early action (default: none)
28
45
  on_hold_start - JS expression executed the instant a hold BEGINS, after
29
46
  the guard/submitting checks pass (default: none). Used to
@@ -83,14 +100,14 @@
83
100
  transform makes it a stacking context), so the bubbles have to be a
84
101
  sibling the button paints over — they only show where they escape its
85
102
  edges, like carbonation coming off the sides. %>
86
- <span class="hold-stack<%= " fizz-lively" if fizz_level.to_s == "lively" %>"<% if fizz_bind %> :style="<%= fizz_bind %>"<% end %><% if fizz_style.present? %> style="<%= fizz_style %>"<% end %><% if fizz_portal %> data-fizz-portal x-init="window.studioFizzPortal && window.studioFizzPortal.mount($el)"<% end %>>
103
+ <span class="hold-stack<%= " fizz-lively" if fizz_level.to_s == "lively" %>"<% if fizz_bind %> :style="<%= fizz_bind %>"<% end %><% if fizz_style.present? %> style="<%= fizz_style %>"<% end %><% if fizz_portal %> data-fizz-portal<% end %> data-studio-controller="hold-button">
87
104
  <%# Fizz layer — bubbles that simmer at rest, boil while the button is held,
88
105
  and burst outward on success. Purely decorative, so it is aria-hidden and
89
106
  pointer-events:none; the table behind it is seeded per hold_id (see Studio::FizzHelper). %>
90
107
  <% if fizz %>
91
- <%# With fizz_portal the layers ride one wrapper, which the script below
92
- moves to <body>. Until it does, the wrapper sits in the stack exactly
93
- where the layers would. %>
108
+ <%# With fizz_portal the layers ride one wrapper, which the hold-button
109
+ controller moves to <body>. Until it does, the wrapper sits in the stack
110
+ exactly where the layers would. %>
94
111
  <% if fizz_portal %>
95
112
  <span class="hold-fizz-portal<%= " fizz-lively" if fizz_level.to_s == "lively" %>" aria-hidden="true">
96
113
  <% end %>
@@ -111,12 +128,11 @@
111
128
  <% if guard %>data-guard="<%= guard %>"<% end %>
112
129
  <% if on_hold_start %>data-on-hold-start="<%= on_hold_start %>"<% end %>
113
130
  <% if on_success %>data-on-success="<%= on_success %>"<% end %>
114
- <% if validate %>data-validate="<%= validate %>" data-validate-at="<%= validate_at %>"<% end %>
115
- <% if early_action %>data-early-action="<%= early_action %>" data-early-action-at="<%= early_action_at %>"<% end %>
131
+ <% if validate %>data-validate="<%= validate %>" data-validate-at="<%= validate_at %>"<% elsif local_assigns.key?(:validate_at) %>data-validate-at="<%= validate_at %>"<% end %>
132
+ <% if early_action %>data-early-action="<%= early_action %>" data-early-action-at="<%= early_action_at %>"<% elsif local_assigns.key?(:early_action_at) %>data-early-action-at="<%= early_action_at %>"<% end %>
116
133
  <% if early_action_guard %>data-early-action-guard="<%= early_action_guard %>"<% end %>
117
134
  style="--duration: <%= duration %>ms"
118
- @mousedown="holdBtnStart($el)" @mouseup="holdBtnEnd($el)" @mouseleave="holdBtnEnd($el)"
119
- @touchstart.prevent="holdBtnStart($el)" @touchend="holdBtnEnd($el)" @touchcancel="holdBtnEnd($el)">
135
+ data-studio-action="mousedown->hold-button#start mouseup->hold-button#end mouseleave->hold-button#end touchstart->hold-button#press touchend->hold-button#end touchcancel->hold-button#end">
120
136
  <div class="hold-icon">
121
137
  <svg class="progress" viewBox="0 0 32 32"><circle r="8" cx="16" cy="16"/></svg>
122
138
  <svg class="tick" viewBox="0 0 24 24"><polyline points="18,7 11,16 6,12"/></svg>
@@ -135,433 +151,10 @@
135
151
  <span class="countdown-num"></span>
136
152
  </div>
137
153
  </button>
154
+ <%# Empty, and out of the layout, on a button that works. The guard
155
+ (studio/_hold_button_guard) fills it when the controller never connects,
156
+ and a live region has to be on the page before its text arrives to be
157
+ announced. %>
158
+ <span class="hold-notice" role="status"></span>
138
159
  </span>
139
160
 
140
- <script>
141
- (function() {
142
- if (window._holdBtnInit) return;
143
- window._holdBtnInit = true;
144
-
145
- var timers = {};
146
- var nudgeTimers = {};
147
- var CIRCUMFERENCE = 2 * Math.PI * 9; // ~56.55, matches r=9
148
-
149
- // Evaluate a JS expression string against an x-data scope. Both `d`
150
- // and `data` are aliased to the scope's reactive $data so call sites
151
- // can use either ("d.runHoldValidations()" or "data.selectionCount").
152
- //
153
- // Sync only. For async expressions (e.g. validate which calls an
154
- // async method), Alpine.evaluate returns SYNCHRONOUSLY — it kicks
155
- // the work off via evaluateLater + callback, and returns whatever
156
- // is in `result` at return time (still undefined for async). Use
157
- // evalInAsync for those.
158
- function evalIn(scope, expr) {
159
- var d = Alpine.$data(scope);
160
- return Alpine.evaluate(scope, expr, { scope: { d: d, data: d } });
161
- }
162
-
163
- // Async-aware version — returns a Promise that resolves to the
164
- // expression's value. For sync expressions, resolves immediately.
165
- // For async ones (Promise-returning methods like
166
- // d.runHoldValidations()), the AsyncFunction flattens the Promise
167
- // so the resolved value bubbles up cleanly.
168
- //
169
- // We DON'T use Alpine.evaluateLater here. In Alpine 3.x the
170
- // `extras` arg shape is version-dependent — passing
171
- // { scope: { d, data } } silently fails to inject those vars in
172
- // some builds (the user hit "d is not defined" when we tried
173
- // that), and the working shape changes between minor versions.
174
- // Compiling our own AsyncFunction with the named params we need
175
- // is portable and doesn't depend on Alpine internals.
176
- var _AsyncFn = Object.getPrototypeOf(async function() {}).constructor;
177
- function evalInAsync(scope, expr) {
178
- var d = Alpine.$data(scope);
179
- try {
180
- var fn = new _AsyncFn('d', 'data', 'Alpine', 'window',
181
- '"use strict"; return (' + expr + ');');
182
- return fn(d, d, Alpine, window);
183
- } catch (e) {
184
- return Promise.reject(e);
185
- }
186
- }
187
-
188
- function updateDebugRing(el, secondsLeft, totalSeconds) {
189
- var debug = el.querySelector('.nudge-debug');
190
- if (!debug) return;
191
- var fill = debug.querySelector('circle.fill');
192
- var num = debug.querySelector('.countdown-num');
193
- var fraction = secondsLeft / totalSeconds;
194
- fill.style.strokeDashoffset = (CIRCUMFERENCE * fraction).toFixed(2);
195
- num.textContent = secondsLeft;
196
- }
197
-
198
- function triggerNudge(el, cls) {
199
- if (el.classList.contains('process') || el.classList.contains('success')) return;
200
- el.classList.remove('nudge', 'nudge-soft');
201
- void el.offsetWidth;
202
- el.classList.add(cls);
203
- }
204
-
205
- function startNudgeCycle(el, softOnly) {
206
- var id = el.dataset.holdId || 'hold';
207
- stopNudgeCycle(id);
208
- nudgeTimers[id] = {};
209
-
210
- var firstDelay = softOnly ? 10 : 3;
211
- var repeatDelay = 10;
212
- var secsLeft = firstDelay;
213
- var total = firstDelay;
214
- var isFirst = !softOnly;
215
-
216
- updateDebugRing(el, secsLeft, total);
217
-
218
- nudgeTimers[id].tick = setInterval(function() {
219
- secsLeft--;
220
- if (secsLeft <= 0) {
221
- triggerNudge(el, isFirst ? 'nudge' : 'nudge-soft');
222
- isFirst = false;
223
- total = repeatDelay;
224
- secsLeft = repeatDelay;
225
- }
226
- updateDebugRing(el, secsLeft, total);
227
- }, 1000);
228
- }
229
-
230
- function stopNudgeCycle(id) {
231
- if (nudgeTimers[id]) {
232
- clearTimeout(nudgeTimers[id].initial);
233
- clearInterval(nudgeTimers[id].interval);
234
- clearInterval(nudgeTimers[id].tick);
235
- delete nudgeTimers[id];
236
- }
237
- }
238
-
239
- // Auto-start nudge for any hold buttons already visible
240
- document.addEventListener('turbo:load', function() {
241
- document.querySelectorAll('.hold-btn').forEach(function(el) {
242
- el.addEventListener('animationend', function(e) {
243
- if (e.animationName === 'hold-nudge' || e.animationName === 'hold-nudge-soft') {
244
- el.classList.remove('nudge', 'nudge-soft');
245
- }
246
- });
247
- startNudgeCycle(el);
248
- });
249
- });
250
-
251
- // Cleanup nudge timers before Turbo caches the page
252
- document.addEventListener('turbo:before-cache', function() {
253
- Object.keys(nudgeTimers).forEach(function(id) { stopNudgeCycle(id); });
254
- });
255
-
256
- // ── The fizz portal (fizz_portal: true) ─────────────────────────────
257
- // Moves a stack's bubble layers to the document body in one fixed box that tracks the
258
- // stack's rect, so a card's overflow clip cannot cut them off. The box
259
- // mirrors the button's state classes and the stack's palette, because the
260
- // CSS can no longer read either through the stack. engine-motion.css,
261
- // THE PORTAL, holds the other half.
262
- var PORTAL_STATES = ['process', 'loading', 'success', 'error', 'nudge', 'nudge-soft'];
263
- var portals = [];
264
-
265
- // One rung above the highest z-index among the stack's ancestors, so the
266
- // bubbles clear the card, the panel or the modal the button sits in,
267
- // and never below --z-raised.
268
- function portalZ(stack) {
269
- var floor = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--z-raised'), 10);
270
- var top = isNaN(floor) ? 20 : floor;
271
- for (var n = stack.parentElement; n && n !== document.body; n = n.parentElement) {
272
- var z = parseInt(getComputedStyle(n).zIndex, 10);
273
- if (!isNaN(z) && z + 1 > top) top = z + 1;
274
- }
275
- return top;
276
- }
277
-
278
- function syncPortalState(p) {
279
- PORTAL_STATES.forEach(function(s) {
280
- p.box.classList.toggle('is-' + s, p.button.classList.contains(s));
281
- });
282
- }
283
-
284
- function syncPortalPalette(p) {
285
- var cs = getComputedStyle(p.stack);
286
- for (var i = 1; i <= <%= Studio::FizzHelper::SLOTS %>; i++) {
287
- var v = cs.getPropertyValue('--fizz-c-' + i).trim();
288
- if (v) p.box.style.setProperty('--fizz-c-' + i, v);
289
- else p.box.style.removeProperty('--fizz-c-' + i);
290
- }
291
- }
292
-
293
- function placePortal(p) {
294
- if (!p.stack.isConnected) { unmountPortal(p, false); return; }
295
- var r = p.stack.getBoundingClientRect();
296
- var shown = p.visible && r.width > 0 && r.height > 0;
297
- p.box.style.visibility = shown ? '' : 'hidden';
298
- if (shown) {
299
- p.box.style.transform = 'translate(' + r.left + 'px, ' + r.top + 'px)';
300
- p.box.style.width = r.width + 'px';
301
- p.box.style.height = r.height + 'px';
302
- if (p.parked) { clearInterval(p.parked); p.parked = null; }
303
- p.frame = requestAnimationFrame(function() { placePortal(p); });
304
- } else {
305
- p.frame = null;
306
- // Parked: no frames run, so nothing would notice the stack leaving the
307
- // DOM (an x-if, a Turbo stream, a closed modal) until the next page
308
- // load. A slow check tears the box and its observers down when it does.
309
- if (!p.parked) {
310
- p.parked = setInterval(function() {
311
- if (!p.stack.isConnected) unmountPortal(p, false);
312
- }, 1000);
313
- }
314
- }
315
- }
316
-
317
- function unmountPortal(p, restore) {
318
- if (p.frame) cancelAnimationFrame(p.frame);
319
- if (p.parked) clearInterval(p.parked);
320
- p.observers.forEach(function(o) { o.disconnect(); });
321
- p.button.removeEventListener('mouseenter', p.enter);
322
- p.button.removeEventListener('mouseleave', p.leave);
323
- if (restore && p.stack.isConnected) {
324
- // Back where the server rendered it, with nothing the script added,
325
- // so a cached snapshot restores to the markup it came from.
326
- p.box.className = p.box.className.replace(/\s*\bis-[\w-]+/g, '');
327
- p.box.removeAttribute('style');
328
- p.box.removeAttribute('data-fizz-portal-for');
329
- p.stack.insertBefore(p.box, p.stack.firstChild);
330
- } else {
331
- p.box.remove();
332
- }
333
- delete p.stack._fizzPortal;
334
- portals = portals.filter(function(q) { return q !== p; });
335
- }
336
-
337
- function mountPortal(stack) {
338
- if (stack._fizzPortal) return stack._fizzPortal;
339
- var box = stack.querySelector(':scope > .hold-fizz-portal');
340
- var button = stack.querySelector(':scope > .hold-btn');
341
- if (!box || !button) return null;
342
-
343
- var p = { stack: stack, box: box, button: button, visible: true, frame: null, parked: null, observers: [] };
344
- stack._fizzPortal = p;
345
- portals.push(p);
346
-
347
- document.body.appendChild(box);
348
- box.classList.add('is-portaled');
349
- box.setAttribute('data-fizz-portal-for', button.dataset.holdId || 'hold');
350
- box.style.zIndex = portalZ(stack);
351
- syncPortalState(p);
352
- syncPortalPalette(p);
353
-
354
- p.enter = function() { box.classList.add('is-hover'); };
355
- p.leave = function() { box.classList.remove('is-hover'); };
356
- button.addEventListener('mouseenter', p.enter);
357
- button.addEventListener('mouseleave', p.leave);
358
-
359
- var states = new MutationObserver(function() { syncPortalState(p); });
360
- states.observe(button, { attributes: true, attributeFilter: ['class'] });
361
- var palette = new MutationObserver(function() { syncPortalPalette(p); });
362
- palette.observe(stack, { attributes: true, attributeFilter: ['style'] });
363
- p.observers.push(states, palette);
364
-
365
- // Track the rect every frame while the stack is on screen: a visible
366
- // portal reads one rect per frame. Park the box while the stack is not
367
- // on screen (scrolled away, inside a closed panel, or clipped out of a
368
- // scroll container): no frames, only a once-a-second check that the
369
- // stack is still in the DOM.
370
- if ('IntersectionObserver' in window) {
371
- var seen = new IntersectionObserver(function(entries) {
372
- p.visible = entries[entries.length - 1].isIntersecting;
373
- if (!p.stack.isConnected) { unmountPortal(p, false); return; }
374
- if (p.visible && !p.frame) placePortal(p);
375
- if (!p.visible) placePortal(p);
376
- });
377
- seen.observe(stack);
378
- p.observers.push(seen);
379
- }
380
- placePortal(p);
381
- return p;
382
- }
383
-
384
- function mountAllPortals() {
385
- portals.slice().forEach(function(p) { if (!p.stack.isConnected) unmountPortal(p, false); });
386
- document.querySelectorAll('.hold-stack[data-fizz-portal]').forEach(mountPortal);
387
- }
388
-
389
- window.studioFizzPortal = {
390
- mount: mountPortal,
391
- mountAll: mountAllPortals,
392
- count: function() { return portals.length; }
393
- };
394
- document.addEventListener('turbo:load', mountAllPortals);
395
- document.addEventListener('turbo:before-cache', function() {
396
- portals.slice().forEach(function(p) { unmountPortal(p, true); });
397
- });
398
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAllPortals);
399
- else mountAllPortals();
400
-
401
- window.holdBtnStart = function(el) {
402
- var holdId = el.dataset.holdId || 'hold';
403
- var scope = el.closest('[x-data]');
404
- if (scope) {
405
- try {
406
- var data = Alpine.$data(scope);
407
- // submitting === true means the previous attempt's POST is
408
- // still in flight. Refuse to start a second hold so we don't
409
- // double-submit and so the spinning button stays visible.
410
- if (data && data.submitting) return;
411
- var guard = el.dataset.guard;
412
- if (guard && !evalIn(scope, guard)) return;
413
- } catch(e) {
414
- console.warn('[hold:' + holdId + '] guard/submitting check threw:', e);
415
- }
416
- } else {
417
- console.warn('[hold:' + holdId + '] no [x-data] ancestor — evalIn calls will be no-ops');
418
- }
419
-
420
- // Hold-START hook (2026-06-13). Fires the instant a hold BEGINS — the
421
- // guard + submitting checks above have already passed, so this only runs
422
- // for a hold that is actually going to count down. Runs in PARALLEL with
423
- // the 2s hold; the board uses it to kick off the authoritative funding
424
- // pre-check fetch (beginFundingCheck) so the result is resolved by the
425
- // time on_success fires. Wrapped — a throwing hook must never abort the
426
- // hold itself.
427
- if (scope) {
428
- var onHoldStart = el.dataset.onHoldStart;
429
- if (onHoldStart) {
430
- try { evalIn(scope, onHoldStart); }
431
- catch (e) { console.error('[hold:' + holdId + '] on_hold_start threw:', e); }
432
- }
433
- }
434
-
435
- var id = el.dataset.holdId || 'hold';
436
- var duration = parseInt(el.dataset.duration || '2000', 10);
437
- var onSuccess = el.dataset.onSuccess;
438
- var validate = el.dataset.validate;
439
- var validateAt = parseInt(el.dataset.validateAt || '750', 10);
440
- var earlyAction = el.dataset.earlyAction;
441
- var earlyActionAt = parseInt(el.dataset.earlyActionAt || '1500', 10);
442
- var earlyActionGuard = el.dataset.earlyActionGuard;
443
-
444
- var circle = el.querySelector('svg.progress circle');
445
-
446
- // Restart nudge cycle from hold start (suppressed visually by .process)
447
- el.classList.remove('nudge', 'nudge-soft');
448
- startNudgeCycle(el);
449
-
450
- // Snap progress to 0 instantly before starting
451
- if (circle) {
452
- circle.style.transition = 'none';
453
- el.classList.remove('process', 'success');
454
- void circle.offsetWidth; // force reflow
455
- circle.style.transition = '';
456
- } else {
457
- el.classList.remove('process', 'success');
458
- }
459
-
460
- el.style.setProperty('--duration', duration + 'ms');
461
- el.classList.add('process');
462
-
463
- timers[id] = setTimeout(function() {
464
- if (onSuccess) {
465
- // Stay in process state briefly while resolving
466
- setTimeout(function() {
467
- try {
468
- var s = el.closest('[x-data]');
469
- if (!s) {
470
- console.warn('[hold:' + holdId + '] on_success: no [x-data] ancestor at fire time');
471
- return;
472
- }
473
- evalIn(s, onSuccess);
474
- } catch(e) {
475
- console.error('[hold:' + holdId + '] on_success threw:', e);
476
- el.classList.remove('process');
477
- el.classList.add('success');
478
- }
479
- }, 500);
480
- } else {
481
- el.classList.remove('process');
482
- el.classList.add('success');
483
- }
484
- }, duration);
485
-
486
- if (earlyAction) {
487
- timers[id + '_early'] = setTimeout(function() {
488
- var s = el.closest('[x-data]');
489
- if (s) {
490
- if (!earlyActionGuard || evalIn(s, earlyActionGuard)) {
491
- clearTimeout(timers[id]);
492
- delete timers[id];
493
- try { evalIn(s, earlyAction); }
494
- catch(e) { console.error('[hold:' + holdId + '] early_action threw:', e); }
495
- }
496
- }
497
- }, earlyActionAt);
498
- }
499
-
500
- if (validate) {
501
- timers[id + '_val'] = setTimeout(function() {
502
- var s = el.closest('[x-data]');
503
- if (!s) {
504
- console.warn('[hold:' + holdId + '] validate: no [x-data] ancestor');
505
- return;
506
- }
507
- // Helper: abort the in-flight hold cleanly. Clears all timers
508
- // for this id and drops the .process class so the user isn't
509
- // stranded at "Almost there…" if validate rejects. Caller's
510
- // validate is responsible for any setHoldError() if it wants
511
- // an explicit error state (e.g. geo block).
512
- var abort = function() {
513
- clearTimeout(timers[id]); delete timers[id];
514
- if (timers[id + '_early']) { clearTimeout(timers[id + '_early']); delete timers[id + '_early']; }
515
- el.classList.remove('process');
516
- var c = el.querySelector('svg.progress circle');
517
- if (c) { c.style.transition = 'none'; void c.offsetWidth; c.style.transition = ''; }
518
- };
519
- try {
520
- evalInAsync(s, validate).then(
521
- function(ok) { if (!ok) abort(); },
522
- function(err) {
523
- console.error('[hold:' + holdId + '] validate rejected:', err);
524
- abort();
525
- }
526
- );
527
- } catch(e) {
528
- console.error('[hold:' + holdId + '] validate threw synchronously:', e);
529
- abort();
530
- }
531
- }, validateAt);
532
- }
533
- };
534
-
535
- window.holdBtnEnd = function(el) {
536
- if (el.classList.contains('success') || el.classList.contains('error')) return;
537
- var id = el.dataset.holdId || 'hold';
538
-
539
- // Snap progress circle back to 0 instantly (no reverse animation)
540
- var circle = el.querySelector('svg.progress circle');
541
- if (circle) {
542
- circle.style.transition = 'none';
543
- el.classList.remove('process');
544
- void circle.offsetWidth; // force reflow
545
- circle.style.transition = '';
546
- } else {
547
- el.classList.remove('process');
548
- }
549
-
550
- if (timers[id]) {
551
- clearTimeout(timers[id]);
552
- delete timers[id];
553
- }
554
- if (timers[id + '_val']) {
555
- clearTimeout(timers[id + '_val']);
556
- delete timers[id + '_val'];
557
- }
558
- if (timers[id + '_early']) {
559
- clearTimeout(timers[id + '_early']);
560
- delete timers[id + '_early'];
561
- }
562
-
563
- // Restart with soft-only nudges (big nudge already seen)
564
- startNudgeCycle(el, true);
565
- };
566
- })();
567
- </script>