@zencemarketing/spin-scratch-sdk 0.1.0-alpha.1

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,3685 @@
1
+ /**
2
+ * SpinWheel SDK v0.1.0-alpha.1
3
+ * (c) 2026 – MIT License
4
+ * A dynamic, configurable Spin & Win wheel and Scratch Card SDK for Vanilla JS & React.
5
+ */
6
+ (function (global, factory) {
7
+ typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
8
+ typeof define === 'function' && define.amd ? define(['exports'], factory) :
9
+ (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.SpinWheelSDK = {}));
10
+ })(this, (function (exports) { 'use strict';
11
+
12
+ /**
13
+ * Generates the full SDK stylesheet as a string, interpolated with theme colors.
14
+ * @param {object} config - Style configuration object
15
+ * @param {object} config.theme - Theme colors
16
+ * @param {string} [config.titleColor] - Custom title color
17
+ * @param {string} [config.subtitleColor] - Custom subtitle color
18
+ * @param {string} [config.backgroundColor] - Custom background color
19
+ * @param {string} [config.backgroundImage] - Custom background image URL
20
+ * @param {string} [config.ringColor] - Custom ring color
21
+ * @param {boolean} [config.ringShadow] - Enable ring shadow
22
+ * @param {boolean} [config.ringAnimation] - Enable ring animation
23
+ * @param {string} [config.pointerColor] - Custom pointer color
24
+ * @param {string} [config.buttonColor] - Custom button color
25
+ * @param {boolean} [config.buttonShadow] - Enable button shadow
26
+ * @param {boolean} [config.buttonAnimation] - Enable button animation
27
+ * @param {string} [config.winCardRedeemButtonColorTop] - Redeem button gradient top
28
+ * @param {string} [config.winCardRedeemButtonColorBottom] - Redeem button gradient bottom
29
+ * @param {string} [config.winCardRedeemButtonTextColor] - Redeem button text color
30
+ * @param {function} hexToRGBA
31
+ * @returns {string}
32
+ */
33
+ function buildCSS(config, hexToRGBA) {
34
+ const t = config.theme || {};
35
+
36
+ // Extract theme colors with fallbacks
37
+ const gold = t.gold || '#e8c547';
38
+ const goldLight = t.goldLight || '#f5d76e';
39
+ const goldDark = t.goldDark || '#c9a227';
40
+ t.bgDark || '#0d0d12';
41
+ const textMuted = t.textMuted || '#9ca3af';
42
+
43
+ // Configurable colors
44
+ const titleColor = config.titleColor || goldLight;
45
+ const subtitleColor = config.subtitleColor || textMuted;
46
+ const ringColor = config.ringColor || gold;
47
+ const pointerColor = config.pointerColor || gold;
48
+ const buttonBaseColor = config.buttonColor || gold;
49
+ const redeemBtnTop = config.winCardRedeemButtonColorTop || '#15803d';
50
+ const redeemBtnBottom = config.winCardRedeemButtonColorBottom || '#166534';
51
+ const redeemBtnText = config.winCardRedeemButtonTextColor || '#ffffff';
52
+
53
+ // Feature toggles
54
+ const ringShadow = config.ringShadow !== false;
55
+ const ringAnimation = config.ringAnimation !== false;
56
+ const buttonShadow = config.buttonShadow !== false;
57
+ const buttonAnimation = config.buttonAnimation !== false;
58
+
59
+ // Computed values
60
+ hexToRGBA(gold, 0.4);
61
+ const ringGlow = hexToRGBA(ringColor, 0.4);
62
+ const buttonGlow = hexToRGBA(buttonBaseColor, 0.4);
63
+
64
+ // Background styles
65
+ const bgStyle = config.backgroundColor
66
+ ? `background-color:${config.backgroundColor};`
67
+ : '';
68
+ const bgImageStyle = config.backgroundImage
69
+ ? `background-image:url('${config.backgroundImage}');background-size:cover;background-position:center;`
70
+ : '';
71
+
72
+ // Ring shadow and animation
73
+ const ringBoxShadow = ringShadow
74
+ ? `box-shadow:0 0 0 2px rgba(0,0,0,.5), 0 0 50px ${ringGlow}, inset 0 2px 4px rgba(255,255,255,.35), inset 0 -2px 4px rgba(0,0,0,.2);`
75
+ : `box-shadow:0 0 0 2px rgba(0,0,0,.5), inset 0 2px 4px rgba(255,255,255,.35), inset 0 -2px 4px rgba(0,0,0,.2);`;
76
+
77
+ const ringAnimationCSS = ringAnimation
78
+ ? `animation:sw-ring-pulse 3s ease-in-out infinite;`
79
+ : '';
80
+
81
+ // Button shadow and animation
82
+ const buttonBoxShadow = buttonShadow
83
+ ? `box-shadow:0 4px 0 ${goldDark}, 0 6px 20px ${buttonGlow}, inset 0 1px 0 rgba(255,255,255,.3);`
84
+ : `box-shadow:0 4px 0 ${goldDark}, inset 0 1px 0 rgba(255,255,255,.3);`;
85
+
86
+ const buttonHoverStyle = buttonAnimation
87
+ ? `.sw-spin-btn:hover:not(:disabled){transform:translateY(-2px);box-shadow:0 6px 0 ${goldDark},0 8px 28px ${buttonGlow},inset 0 1px 0 rgba(255,255,255,.3)}`
88
+ : `.sw-spin-btn:hover:not(:disabled){filter:brightness(1.05)}`;
89
+
90
+ const buttonActiveStyle = buttonAnimation
91
+ ? `.sw-spin-btn:active:not(:disabled){transform:translateY(2px);box-shadow:0 2px 0 ${goldDark},0 4px 15px ${buttonGlow},inset 0 1px 0 rgba(255,255,255,.2)}`
92
+ : `.sw-spin-btn:active:not(:disabled){transform:translateY(1px)}`;
93
+
94
+ return `
95
+ /* === SpinWheel SDK v2.1 === */
96
+ @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@700;800&family=Outfit:wght@300;500;600;700&display=swap');
97
+
98
+ .sw-root *, .sw-overlay * { margin:0; padding:0; box-sizing:border-box; }
99
+
100
+ .sw-root {
101
+ font-family:'Outfit',sans-serif;
102
+ display:flex; flex-direction:column; align-items:center; width:100%;
103
+ ${bgStyle}
104
+ ${bgImageStyle}
105
+ }
106
+
107
+ /* ── header ── */
108
+ .sw-header { text-align:center; margin-bottom:.5rem; }
109
+ .sw-title {
110
+ font-family:'Playfair Display',serif;
111
+ font-size:clamp(2rem,6vw,3.5rem); font-weight:800; letter-spacing:.02em;
112
+ }
113
+ .sw-title .sw-spin-text {
114
+ color:${titleColor};
115
+ text-shadow:0 0 30px ${hexToRGBA(titleColor, 0.4)}, 0 0 60px ${hexToRGBA(titleColor, 0.2)};
116
+ }
117
+ .sw-title .sw-win-text { color:${goldDark}; }
118
+ .sw-subtitle { margin-top:.5rem; color:${subtitleColor}; font-size:1rem; font-weight:300; }
119
+
120
+ /* ── wheel ── */
121
+ .sw-wheel-container {
122
+ position:relative; width:min(340px,90vw); aspect-ratio:1; margin:2rem; padding-top:28px;
123
+ }
124
+ .sw-wheel-outer {
125
+ position:absolute; inset:-10px; border-radius:50%;
126
+ background:linear-gradient(145deg,${goldLight} 0%,${ringColor} 25%,${goldDark} 50%,${ringColor} 75%,${goldDark} 100%);
127
+ padding:8px;
128
+ ${ringBoxShadow}
129
+ ${ringAnimationCSS}
130
+ }
131
+ ${ringAnimation ? `
132
+ @keyframes sw-ring-pulse {
133
+ 0%,100%{box-shadow:0 0 0 2px rgba(0,0,0,.5),0 0 50px ${ringGlow},inset 0 2px 4px rgba(255,255,255,.35),inset 0 -2px 4px rgba(0,0,0,.2)}
134
+ 50%{box-shadow:0 0 0 2px rgba(0,0,0,.5),0 0 65px ${hexToRGBA(ringColor, 0.5)},inset 0 2px 6px rgba(255,255,255,.4),inset 0 -2px 4px rgba(0,0,0,.2)}
135
+ }` : ''}
136
+ .sw-wheel-inner {
137
+ width:100%; height:100%; border-radius:50%; overflow:hidden;
138
+ position:relative; background:transparent;
139
+ box-shadow:inset 0 0 0 3px rgba(0,0,0,.6);
140
+ }
141
+ .sw-wheel {
142
+ width:100%; height:100%; border-radius:50%; position:relative;
143
+ transition:transform .1s linear; will-change:transform;
144
+ }
145
+ .sw-wheel.sw-spinning { transition:none; }
146
+
147
+ /* ── hub ── */
148
+ .sw-wheel-hub {
149
+ position:absolute; left:50%; top:50%; transform:translate(-50%,-50%);
150
+ width:20%; height:20%; min-width:52px; min-height:52px; border-radius:50%;
151
+ background:linear-gradient(145deg,${goldLight} 0%,${gold} 40%,${goldDark} 100%);
152
+ box-shadow:0 3px 12px rgba(0,0,0,.4), inset 0 2px 4px rgba(255,255,255,.4), inset 0 -1px 2px rgba(0,0,0,.2);
153
+ display:flex; flex-direction:column; align-items:center; justify-content:center;
154
+ z-index:10; cursor:pointer; transition:transform .15s ease, filter .15s ease;
155
+ }
156
+ .sw-wheel-hub:hover { transform:translate(-50%,-50%) scale(1.05); filter:brightness(1.1); }
157
+ .sw-wheel-hub:active { transform:translate(-50%,-50%) scale(.98); }
158
+ .sw-wheel-hub-icon { font-size:12px; color:#1a1a1f; line-height:1; margin-bottom:1px; }
159
+ .sw-wheel-hub-brand { font-size:8px; font-weight:700; color:#1a1a1f; letter-spacing:.02em; text-transform:lowercase; }
160
+ .sw-wheel-hub-brand::first-letter { text-transform:uppercase; }
161
+
162
+ /* ── segments ── */
163
+ .sw-segment {
164
+ position:absolute; width:50%; height:50%; left:50%; top:50%;
165
+ transform-origin:0% 0%; display:flex; align-items:center; justify-content:center; border:none;
166
+ }
167
+ .sw-segment-content {
168
+ position:absolute; width:70%; top:30%; text-align:center; color:#000; font-weight:600;
169
+ font-size:clamp(.6rem,2vw,.8rem); text-shadow:0 1px 3px rgb(220 220 220/50%);
170
+ display:flex; flex-direction:row; align-items:center; justify-content:center;
171
+ gap:4px; word-wrap:break-word; left:30%;
172
+ }
173
+ .sw-segment-icon { font-size:1em; line-height:1; flex-shrink:0; }
174
+ .sw-segment-label { display:inline-block; }
175
+
176
+ /* ── pointer ── */
177
+ .sw-pointer {
178
+ position:absolute; top:4px; left:50%; transform:translateX(-50%); z-index:20; width:0; height:0;
179
+ }
180
+ .sw-pointer-outer {
181
+ position:absolute; left:50%; transform:translateX(-50%); width:0; height:0;
182
+ border-left:22px solid transparent; border-right:22px solid transparent;
183
+ border-top:36px solid ${pointerColor};
184
+ filter:drop-shadow(0 3px 8px rgba(0,0,0,.5)) drop-shadow(0 0 10px ${hexToRGBA(pointerColor, 0.4)}); top:-40px;
185
+ }
186
+ .sw-pointer.sw-highlight .sw-pointer-outer {
187
+ animation:sw-pointer-bounce .5s cubic-bezier(.34,1.56,.64,1);
188
+ }
189
+ @keyframes sw-pointer-bounce {
190
+ 0%{transform:translateX(-50%) scale(1)}
191
+ 40%{transform:translateX(-50%) scale(1.12)}
192
+ 100%{transform:translateX(-50%) scale(1)}
193
+ }
194
+
195
+ /* ── spin button ── */
196
+ .sw-spin-btn {
197
+ font-family:'Outfit',sans-serif; font-size:1.25rem; font-weight:700;
198
+ letter-spacing:.15em; padding:1rem 3rem; border:none; border-radius:12px;
199
+ background:linear-gradient(180deg,${goldLight},${goldDark}); color:#1a1a1f;
200
+ cursor:pointer;
201
+ ${buttonBoxShadow}
202
+ transition:transform .15s ease, box-shadow .2s ease, filter .2s ease;
203
+ }
204
+ ${buttonHoverStyle}
205
+ ${buttonActiveStyle}
206
+ .sw-spin-btn:disabled{opacity:.6;cursor:not-allowed}
207
+
208
+ /* ── overlay & win card ── */
209
+ .sw-overlay {
210
+ position:fixed; inset:0; background:rgba(0,0,0,.6); backdrop-filter:blur(6px);
211
+ display:flex; align-items:center; justify-content:center;
212
+ z-index:100; opacity:0; visibility:hidden;
213
+ transition:opacity .4s ease, visibility .4s ease;
214
+ font-family:'Outfit',sans-serif;
215
+ }
216
+ .sw-overlay.sw-visible { opacity:1; visibility:visible; }
217
+
218
+ .sw-win-card {
219
+ position:relative; max-width:340px; width:90%; border-radius:12px; overflow:hidden;
220
+ box-shadow:0 25px 50px -12px rgba(0,0,0,.5), 0 0 0 1px rgba(0,0,0,.1);
221
+ transform:scale(.6); opacity:0; transition:none;
222
+ }
223
+ .sw-overlay.sw-visible .sw-win-card {
224
+ animation:sw-card-flare .6s cubic-bezier(.34,1.56,.64,1) forwards;
225
+ }
226
+ @keyframes sw-card-flare {
227
+ 0%{transform:scale(.6);opacity:0;filter:blur(8px)}
228
+ 50%{transform:scale(1.08);opacity:1;filter:blur(0)}
229
+ 70%{transform:scale(.98)}
230
+ 100%{transform:scale(1);opacity:1;filter:blur(0)}
231
+ }
232
+
233
+ .sw-win-card-top {
234
+ background:linear-gradient(160deg,#fff,#fff); padding:1.75rem 1.25rem;
235
+ text-align:center; position:relative;
236
+ }
237
+ .sw-win-card-product {
238
+ width:100px; height:100px; margin:0 auto .75rem; border-radius:12px;
239
+ display:flex; align-items:center; justify-content:center; font-size:3rem;
240
+ box-shadow:0 8px 24px rgba(0,0,0,.3); background:linear-gradient(145deg,#252530,#1c1c24);
241
+ }
242
+ .sw-win-card-brand { font-size:1rem; font-weight:600; color:#000; letter-spacing:.05em; }
243
+
244
+ .sw-win-card-perf { height:14px; background:#fff; position:relative; }
245
+ .sw-win-card-perf::after {
246
+ content:''; position:absolute; left:0; right:0; top:-7px; height:14px;
247
+ background:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='14' viewBox='0 0 12 14'%3E%3Cpath fill='%231a1a22' d='M0 14V7h2v7H0zm2 0V4h2v10H2zm2 0V7h2v7H4zm2 0V4h2v10H6zm2 0V7h2v7H8zm2 0V0h2v14h-2z'/%3E%3C/svg%3E") repeat-x;
248
+ background-size:12px 14px;
249
+ }
250
+
251
+ .sw-win-card-mid {
252
+ background:#fff; padding:1.25rem 1.25rem 1.25rem 1rem; position:relative;
253
+ }
254
+ .sw-win-card-title {
255
+ font-size:1rem; font-weight:700; color:#1a1a1f;
256
+ text-transform:lowercase; margin-bottom:.25rem; line-height:1.3;
257
+ }
258
+ .sw-win-card-title::first-letter { text-transform:uppercase; }
259
+ .sw-win-card-worth { font-size:.8rem; color:#6b7280; margin-bottom:.75rem; font-weight:500; }
260
+ .sw-win-card-code-wrap { display:flex; align-items:center; gap:.5rem; }
261
+ .sw-win-card-code {
262
+ flex:1; font-size:.85rem; font-weight:600; letter-spacing:.08em;
263
+ color:#374151; background:#f3f4f6; border:none; border-radius:8px;
264
+ padding:.5rem .75rem; font-family:'Outfit',monospace;
265
+ }
266
+ .sw-win-card-copy {
267
+ width:40px; height:40px; border-radius:8px; border:none;
268
+ background:#e5e7eb; color:#4b5563; cursor:pointer;
269
+ display:flex; align-items:center; justify-content:center;
270
+ transition:background .2s, color .2s;
271
+ }
272
+ .sw-win-card-copy:hover { background:#d1d5db; color:#1f2937; }
273
+ .sw-win-card-copy svg { width:18px; height:18px; }
274
+
275
+ .sw-win-card-bot {
276
+ background:linear-gradient(180deg,${redeemBtnTop},${redeemBtnBottom});
277
+ padding:1rem 1.25rem; display:flex; align-items:center;
278
+ justify-content:center; gap:.5rem; cursor:pointer;
279
+ transition:filter .2s; position:relative;
280
+ }
281
+ .sw-win-card-bot:hover { filter:brightness(1.08); }
282
+ .sw-win-card-redeem { font-size:1rem; font-weight:700; color:${redeemBtnText}; letter-spacing:.02em; }
283
+ .sw-win-card-arrow { width:20px; height:20px; color:${redeemBtnText}; }
284
+
285
+ /* ── confetti ── */
286
+ .sw-confetti {
287
+ position:fixed; inset:0; pointer-events:none; z-index:99; overflow:hidden;
288
+ }
289
+ .sw-confetti span {
290
+ position:absolute; width:10px; height:10px; border-radius:2px;
291
+ animation:sw-confetti-fall 1.2s ease-out forwards; opacity:0;
292
+ }
293
+ .sw-overlay.sw-visible .sw-confetti span { opacity:1; }
294
+ @keyframes sw-confetti-fall {
295
+ 0%{transform:translateY(-20px) rotate(0deg);opacity:1}
296
+ 100%{transform:translateY(100vh) rotate(720deg);opacity:0}
297
+ }
298
+
299
+ /* ── prefers-reduced-motion ── */
300
+ @media (prefers-reduced-motion: reduce) {
301
+ .sw-wheel { transition:none !important; }
302
+ .sw-wheel-outer { animation:none !important; }
303
+ .sw-spin-btn { transition:none !important; }
304
+ .sw-spin-btn:hover:not(:disabled) { transform:none !important; }
305
+ .sw-spin-btn:active:not(:disabled) { transform:none !important; }
306
+ .sw-overlay.sw-visible .sw-win-card { animation:none !important; opacity:1; transform:scale(1); }
307
+ .sw-confetti span { animation:none !important; display:none; }
308
+ .sw-pointer.sw-highlight .sw-pointer-outer { animation:none !important; }
309
+ }
310
+ `;
311
+ }
312
+
313
+ /**
314
+ * SpinWheel SDK – Utility helpers
315
+ * Pure functions with no side effects.
316
+ */
317
+
318
+ /**
319
+ * Convert a hex colour (#rrggbb) to an rgba() string.
320
+ * @param {string} hex
321
+ * @param {number} alpha
322
+ * @returns {string}
323
+ */
324
+ function hexToRGBA(hex, alpha) {
325
+ if (!hex || typeof hex !== 'string') return `rgba(0,0,0,${alpha})`;
326
+ const cleanHex = hex.replace('#', '');
327
+ const r = parseInt(cleanHex.slice(0, 2), 16) || 0;
328
+ const g = parseInt(cleanHex.slice(2, 4), 16) || 0;
329
+ const b = parseInt(cleanHex.slice(4, 6), 16) || 0;
330
+ return `rgba(${r},${g},${b},${alpha})`;
331
+ }
332
+
333
+ /**
334
+ * Check if a value is a plain object (not array, null, etc.)
335
+ * @param {any} item
336
+ * @returns {boolean}
337
+ */
338
+ function isPlainObject(item) {
339
+ return item !== null && typeof item === 'object' && !Array.isArray(item) && item.constructor === Object;
340
+ }
341
+
342
+ /**
343
+ * Deep merge multiple objects together.
344
+ * Later objects override earlier ones. Arrays are replaced, not merged.
345
+ * @param {object} target - The target object to merge into
346
+ * @param {...object} sources - Source objects to merge from
347
+ * @returns {object} The merged object
348
+ */
349
+ function deepMerge(target, ...sources) {
350
+ if (!sources.length) return target;
351
+ const source = sources.shift();
352
+
353
+ if (isPlainObject(target) && isPlainObject(source)) {
354
+ for (const key in source) {
355
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
356
+ if (isPlainObject(source[key])) {
357
+ if (!target[key]) {
358
+ target[key] = {};
359
+ }
360
+ deepMerge(target[key], source[key]);
361
+ } else if (source[key] !== undefined) {
362
+ target[key] = source[key];
363
+ }
364
+ }
365
+ }
366
+ }
367
+
368
+ return deepMerge(target, ...sources);
369
+ }
370
+
371
+ /**
372
+ * Generate a random alphanumeric coupon code.
373
+ * @param {number} [len=9]
374
+ * @returns {string}
375
+ */
376
+ function randomCode(len = 9) {
377
+ const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
378
+ let code = '';
379
+ for (let i = 0; i < len; i++) {
380
+ code += chars[Math.floor(Math.random() * chars.length)];
381
+ }
382
+ return code;
383
+ }
384
+
385
+ /**
386
+ * Build a CSS `clip-path: polygon(…)` for a pie-slice with a curved outer edge.
387
+ * @param {number} angleDeg – angular width of one segment in degrees
388
+ * @returns {string}
389
+ */
390
+ function buildClipPath(angleDeg) {
391
+ const points = ['0 0'];
392
+ const steps = Math.max(12, Math.round(angleDeg / 3));
393
+ const rad = (angleDeg * Math.PI) / 180;
394
+ for (let i = 0; i <= steps; i++) {
395
+ const a = (i / steps) * rad;
396
+ const x = (Math.cos(a) * 100).toFixed(2);
397
+ const y = (Math.sin(a) * 100).toFixed(2);
398
+ points.push(`${x}% ${y}%`);
399
+ }
400
+ return `polygon(${points.join(', ')})`;
401
+ }
402
+
403
+ /**
404
+ * Text rotation angle so label reads along the bisector of a segment.
405
+ * @param {number} angleDeg
406
+ * @returns {number}
407
+ */
408
+ function textRotation(angleDeg) {
409
+ return 90 + angleDeg / 2;
410
+ }
411
+
412
+ /**
413
+ * Escape HTML special characters.
414
+ * @param {string} str
415
+ * @returns {string}
416
+ */
417
+ function escapeHtml(str) {
418
+ if (!str) return '';
419
+ return String(str)
420
+ .replace(/&/g, '&amp;')
421
+ .replace(/</g, '&lt;')
422
+ .replace(/>/g, '&gt;')
423
+ .replace(/"/g, '&quot;');
424
+ }
425
+
426
+ /** SVG markup for the copy icon */
427
+ const COPY_ICON_SVG =
428
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
429
+
430
+ /** SVG markup for the check (copied) icon */
431
+ const CHECK_ICON_SVG =
432
+ '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>';
433
+
434
+ /** SVG markup for the arrow icon */
435
+ const ARROW_ICON_SVG =
436
+ '<svg class="sw-win-card-arrow" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/><polyline points="12 5 19 12 12 19"/></svg>';
437
+
438
+ /** Default confetti palette */
439
+ const CONFETTI_COLORS = ['#e8c547', '#dc2626', '#16a34a', '#2563eb', '#7c3aed', '#ea580c'];
440
+
441
+ /**
442
+ * SpinWheel SDK v0.1.0
443
+ *
444
+ * A fully dynamic, configurable Spin & Win wheel.
445
+ * ES6 class-based. Outputs: UMD · ESM · CJS via Rollup.
446
+ *
447
+ * @example
448
+ * import { SpinWheel } from 'spin-wheel-sdk';
449
+ *
450
+ * const wheel = SpinWheel.init({
451
+ * container: '#app',
452
+ * segments: [
453
+ * { label: 'Prize 1', color: '#55efc4', icon: '🎁' },
454
+ * { label: 'Prize 2', color: '#81ecec', icon: '✨' },
455
+ * ],
456
+ * onSpinEnd(prize, idx) { console.log(prize); },
457
+ * });
458
+ */
459
+
460
+
461
+ /* ------------------------------------------------------------------ */
462
+ /* Default configuration */
463
+ /* ------------------------------------------------------------------ */
464
+
465
+ /** @type {SpinWheelOptions} */
466
+ const DEFAULTS$1 = Object.freeze({
467
+ // ── Core ──
468
+ container: null,
469
+ segments: [],
470
+ winningIndex: null,
471
+
472
+ // ── Spin Behavior ──
473
+ spinDuration: 5000,
474
+ minSpins: 5,
475
+ maxSpins: 8,
476
+ spinLimit: null, // null = unlimited, number = max spins allowed
477
+
478
+ // ── Header UI ──
479
+ headerTitle: null,
480
+ headerSubtitle: null,
481
+ titleColor: null, // uses theme.goldLight if null
482
+ subtitleColor: null, // uses theme.textMuted if null
483
+
484
+ // ── Hub (center button) ──
485
+ hubLabel: 'Spin & win',
486
+ hubIcon: '▲',
487
+
488
+ // ── Spin Button ──
489
+ showButton: true,
490
+ buttonText: 'SPIN!',
491
+
492
+ // ── Wheel Appearance ──
493
+ backgroundColor: null, // page/container background
494
+ backgroundImage: null, // optional background image URL
495
+ ringColor: null, // uses theme gradient if null
496
+ ringShadow: true,
497
+ ringAnimation: true, // pulsing ring glow animation
498
+ pointerColor: null, // uses theme.gold if null
499
+ buttonColor: null, // uses theme gradient if null
500
+ buttonShadow: true,
501
+ buttonAnimation: true, // hover/active animations
502
+
503
+ // ── Win Card ──
504
+ showWinCard: true,
505
+ generateCode: true,
506
+ codeLength: 9,
507
+ redeemUrl: null,
508
+ winCardBrandLabel: null, // uses hubLabel if null
509
+ winCardWorthLabel: 'WORTH',
510
+ winCardRedeemButtonText: 'Redeem Now',
511
+ winCardRedeemButtonColorTop: '#15803d',
512
+ winCardRedeemButtonColorBottom: '#166534',
513
+ winCardRedeemButtonTextColor: '#ffffff',
514
+
515
+ // ── Confetti ──
516
+ confettiOnWin: true,
517
+ confettiColors: null, // uses default CONFETTI_COLORS if null
518
+ confettiCount: 40,
519
+
520
+ // ── Theme Colors ──
521
+ theme: Object.freeze({
522
+ gold: '#e8c547',
523
+ goldLight: '#f5d76e',
524
+ goldDark: '#c9a227',
525
+ bgDark: '#0d0d12',
526
+ textMuted: '#9ca3af',
527
+ }),
528
+
529
+ // ── Callbacks ──
530
+ onSpinStart: null,
531
+ onSpinEnd: null,
532
+ onRedeem: null,
533
+ onSpinLimitReached: null,
534
+ });
535
+
536
+ /* ------------------------------------------------------------------ */
537
+ /* Validation */
538
+ /* ------------------------------------------------------------------ */
539
+
540
+ function _validateOptions$1(opts) {
541
+ // Container — truly required, cannot default
542
+ if (!opts.container) {
543
+ throw new Error('[SpinWheel] "container" is required.');
544
+ }
545
+
546
+ // Segments — truly required, cannot default
547
+ if (!Array.isArray(opts.segments) || opts.segments.length < 2) {
548
+ throw new Error('[SpinWheel] "segments" must be an array with at least 2 items.');
549
+ }
550
+ for (let i = 0; i < opts.segments.length; i++) {
551
+ const s = opts.segments[i];
552
+ if (!s || typeof s !== 'object') {
553
+ throw new Error(`[SpinWheel] segments[${i}] must be an object.`);
554
+ }
555
+ if (!s.label || typeof s.label !== 'string') {
556
+ throw new Error(`[SpinWheel] segments[${i}].label is required and must be a string.`);
557
+ }
558
+ if (!s.color || typeof s.color !== 'string') {
559
+ throw new Error(`[SpinWheel] segments[${i}].color is required and must be a string.`);
560
+ }
561
+ }
562
+
563
+ // ── Clamp / auto-fix numeric & enum values instead of crashing ──
564
+
565
+ // spinDuration: clamp to min 500
566
+ if (opts.spinDuration !== undefined) {
567
+ if (typeof opts.spinDuration !== 'number' || isNaN(opts.spinDuration)) {
568
+ console.warn('[SpinWheel] "spinDuration" must be a number. Using default (5000).');
569
+ opts.spinDuration = DEFAULTS$1.spinDuration;
570
+ } else if (opts.spinDuration <= 0) {
571
+ console.warn(`[SpinWheel] "spinDuration" (${opts.spinDuration}) must be positive. Clamping to 500.`);
572
+ opts.spinDuration = 500;
573
+ }
574
+ }
575
+
576
+ // codeLength: clamp to min 1
577
+ if (opts.codeLength !== undefined) {
578
+ if (typeof opts.codeLength !== 'number' || isNaN(opts.codeLength)) {
579
+ console.warn('[SpinWheel] "codeLength" must be a number. Using default (9).');
580
+ opts.codeLength = DEFAULTS$1.codeLength;
581
+ } else if (opts.codeLength < 1) {
582
+ console.warn(`[SpinWheel] "codeLength" (${opts.codeLength}) is below 1. Clamping to 1.`);
583
+ opts.codeLength = 1;
584
+ } else {
585
+ opts.codeLength = Math.floor(opts.codeLength);
586
+ }
587
+ }
588
+
589
+ // confettiCount: clamp to min 0
590
+ if (opts.confettiCount !== undefined) {
591
+ if (typeof opts.confettiCount !== 'number' || isNaN(opts.confettiCount)) {
592
+ console.warn('[SpinWheel] "confettiCount" must be a number. Using default (40).');
593
+ opts.confettiCount = DEFAULTS$1.confettiCount;
594
+ } else if (opts.confettiCount < 0) {
595
+ console.warn(`[SpinWheel] "confettiCount" (${opts.confettiCount}) is negative. Clamping to 0.`);
596
+ opts.confettiCount = 0;
597
+ }
598
+ }
599
+
600
+ // minSpins & maxSpins: ensure min <= max, swap if needed
601
+ if (opts.minSpins !== undefined) {
602
+ if (typeof opts.minSpins !== 'number' || isNaN(opts.minSpins)) {
603
+ console.warn('[SpinWheel] "minSpins" must be a number. Using default (5).');
604
+ opts.minSpins = DEFAULTS$1.minSpins;
605
+ } else if (opts.minSpins < 1) {
606
+ console.warn(`[SpinWheel] "minSpins" (${opts.minSpins}) is below 1. Clamping to 1.`);
607
+ opts.minSpins = 1;
608
+ }
609
+ }
610
+ if (opts.maxSpins !== undefined) {
611
+ if (typeof opts.maxSpins !== 'number' || isNaN(opts.maxSpins)) {
612
+ console.warn('[SpinWheel] "maxSpins" must be a number. Using default (8).');
613
+ opts.maxSpins = DEFAULTS$1.maxSpins;
614
+ } else if (opts.maxSpins < 1) {
615
+ console.warn(`[SpinWheel] "maxSpins" (${opts.maxSpins}) is below 1. Clamping to 1.`);
616
+ opts.maxSpins = 1;
617
+ }
618
+ }
619
+ const min = opts.minSpins ?? DEFAULTS$1.minSpins;
620
+ const max = opts.maxSpins ?? DEFAULTS$1.maxSpins;
621
+ if (typeof min === 'number' && typeof max === 'number' && min > max) {
622
+ console.warn(`[SpinWheel] "minSpins" (${min}) is greater than "maxSpins" (${max}). Swapping values.`);
623
+ opts.minSpins = max;
624
+ opts.maxSpins = min;
625
+ }
626
+
627
+ // spinLimit: clamp to min 1 if not null
628
+ if (opts.spinLimit !== undefined && opts.spinLimit !== null) {
629
+ if (typeof opts.spinLimit !== 'number' || isNaN(opts.spinLimit)) {
630
+ console.warn('[SpinWheel] "spinLimit" must be a positive number or null. Setting to null (unlimited).');
631
+ opts.spinLimit = null;
632
+ } else if (opts.spinLimit < 1) {
633
+ console.warn(`[SpinWheel] "spinLimit" (${opts.spinLimit}) is below 1. Clamping to 1.`);
634
+ opts.spinLimit = 1;
635
+ }
636
+ }
637
+
638
+ // winningIndex: clamp to valid segment range
639
+ if (opts.winningIndex !== undefined && opts.winningIndex !== null) {
640
+ if (typeof opts.winningIndex !== 'number' || isNaN(opts.winningIndex)) {
641
+ console.warn('[SpinWheel] "winningIndex" must be a number or null. Setting to null (random).');
642
+ opts.winningIndex = null;
643
+ } else if (opts.winningIndex < 0) {
644
+ console.warn(`[SpinWheel] "winningIndex" (${opts.winningIndex}) is negative. Clamping to 0.`);
645
+ opts.winningIndex = 0;
646
+ } else if (opts.winningIndex >= opts.segments.length) {
647
+ const maxIdx = opts.segments.length - 1;
648
+ console.warn(`[SpinWheel] "winningIndex" (${opts.winningIndex}) exceeds max (${maxIdx}). Clamping to ${maxIdx}.`);
649
+ opts.winningIndex = maxIdx;
650
+ }
651
+ }
652
+ }
653
+
654
+ /* ------------------------------------------------------------------ */
655
+ /* SSR guard */
656
+ /* ------------------------------------------------------------------ */
657
+
658
+ const _isBrowser$1 = typeof window !== 'undefined' && typeof document !== 'undefined';
659
+
660
+ /** @returns {boolean} Whether the user prefers reduced motion */
661
+ function _prefersReducedMotion$1() {
662
+ return _isBrowser$1 && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
663
+ }
664
+
665
+ /* ------------------------------------------------------------------ */
666
+ /* CSS injection (once per page) */
667
+ /* ------------------------------------------------------------------ */
668
+
669
+ let _injectedThemeKey$1 = null;
670
+
671
+ function injectCSS$1(options) {
672
+ if (!_isBrowser$1) return;
673
+ const theme = { ...DEFAULTS$1.theme, ...(options.theme || {}) };
674
+
675
+ // Build a key from all style-affecting options
676
+ const styleConfig = {
677
+ theme,
678
+ titleColor: options.titleColor,
679
+ subtitleColor: options.subtitleColor,
680
+ backgroundColor: options.backgroundColor,
681
+ backgroundImage: options.backgroundImage,
682
+ ringColor: options.ringColor,
683
+ ringShadow: options.ringShadow,
684
+ ringAnimation: options.ringAnimation,
685
+ pointerColor: options.pointerColor,
686
+ buttonColor: options.buttonColor,
687
+ buttonShadow: options.buttonShadow,
688
+ buttonAnimation: options.buttonAnimation,
689
+ winCardRedeemButtonColorTop: options.winCardRedeemButtonColorTop,
690
+ winCardRedeemButtonColorBottom: options.winCardRedeemButtonColorBottom,
691
+ winCardRedeemButtonTextColor: options.winCardRedeemButtonTextColor,
692
+ };
693
+
694
+ const key = JSON.stringify(styleConfig);
695
+ if (_injectedThemeKey$1 === key) return;
696
+
697
+ // Remove old stylesheet if exists (SSR-safe)
698
+ const existing = _isBrowser$1 ? document.querySelector('style[data-spin-wheel-sdk]') : null;
699
+ if (existing) existing.remove();
700
+
701
+ _injectedThemeKey$1 = key;
702
+ const style = document.createElement('style');
703
+ style.setAttribute('data-spin-wheel-sdk', '');
704
+ style.textContent = buildCSS(styleConfig, hexToRGBA);
705
+ document.head.appendChild(style);
706
+ }
707
+
708
+ /* ------------------------------------------------------------------ */
709
+ /* SpinWheel class */
710
+ /* ------------------------------------------------------------------ */
711
+
712
+ class SpinWheelInstance {
713
+ /**
714
+ * @param {SpinWheelOptions} opts
715
+ */
716
+ constructor(opts) {
717
+ if (!_isBrowser$1) {
718
+ throw new Error('[SpinWheel] SpinWheel requires a browser environment (window + document).');
719
+ }
720
+
721
+ // ── validate options ──
722
+ _validateOptions$1(opts);
723
+
724
+ /** @type {SpinWheelOptions} */
725
+ this.options = deepMerge({}, DEFAULTS$1, opts);
726
+
727
+ // Ensure theme is properly merged
728
+ this.options.theme = { ...DEFAULTS$1.theme, ...(opts.theme || {}) };
729
+
730
+ /** Current cumulative rotation in degrees */
731
+ this.currentRotation = 0;
732
+
733
+ /** Whether the wheel is currently animating */
734
+ this.isSpinning = false;
735
+
736
+ /** Set to true after .destroy() */
737
+ this._destroyed = false;
738
+
739
+ /** Last prize won */
740
+ this._lastWonPrize = null;
741
+
742
+ /** Last won index */
743
+ this._lastWonIndex = -1;
744
+
745
+ /** Number of spins performed */
746
+ this._spinCount = 0;
747
+
748
+ /** Active requestAnimationFrame ID (for cancellation on destroy) */
749
+ this._rafId = null;
750
+
751
+ /** Bound listeners stored for removal in destroy() */
752
+ this._boundListeners = {};
753
+
754
+ // ── resolve container ──
755
+ this.containerEl =
756
+ typeof this.options.container === 'string'
757
+ ? document.querySelector(this.options.container)
758
+ : this.options.container;
759
+
760
+ if (!this.containerEl) {
761
+ throw new Error('[SpinWheel] container not found. Ensure the container element exists in the DOM.');
762
+ }
763
+
764
+ /** Angle per segment in degrees */
765
+ this.segmentAngle = 360 / this.options.segments.length;
766
+
767
+ // ── inject CSS (idempotent) ──
768
+ injectCSS$1(this.options);
769
+
770
+ // ── DOM refs (set in _build) ──
771
+ /** @type {HTMLElement} */ this._rootEl = null;
772
+ /** @type {HTMLElement} */ this._wheelEl = null;
773
+ /** @type {HTMLElement} */ this._hubEl = null;
774
+ /** @type {HTMLElement} */ this._pointerEl = null;
775
+ /** @type {HTMLButtonElement} */ this._btnEl = null;
776
+ /** @type {HTMLElement} */ this._overlayEl = null;
777
+ /** @type {HTMLElement} */ this._confettiEl = null;
778
+ /** @type {HTMLElement} */ this._cardProductEl = null;
779
+ /** @type {HTMLElement} */ this._cardBrandEl = null;
780
+ /** @type {HTMLElement} */ this._cardTitleEl = null;
781
+ /** @type {HTMLElement} */ this._cardWorthEl = null;
782
+ /** @type {HTMLInputElement} */ this._cardCodeEl = null;
783
+ /** @type {HTMLButtonElement} */ this._cardCopyEl = null;
784
+ /** @type {HTMLElement} */ this._cardRedeemEl = null;
785
+ /** @type {HTMLElement} */ this._cardRedeemTextEl = null;
786
+
787
+ // ── build & bind ──
788
+ this._build();
789
+ this._bind();
790
+ }
791
+
792
+ /* ================================================================ */
793
+ /* DOM construction */
794
+ /* ================================================================ */
795
+
796
+ /** Build the full widget DOM tree and attach it to the container. */
797
+ _build() {
798
+ const o = this.options;
799
+
800
+ // ── root wrapper ──
801
+ const root = document.createElement('div');
802
+ root.className = 'sw-root';
803
+
804
+ // ── header (optional) ──
805
+ if (o.headerTitle) {
806
+ const header = document.createElement('header');
807
+ header.className = 'sw-header';
808
+ const parts = o.headerTitle.split('&');
809
+ header.innerHTML =
810
+ `<h1 class="sw-title">` +
811
+ `<span class="sw-spin-text">${escapeHtml(parts[0] || 'Spin')}</span>` +
812
+ `${parts.length > 1 ? ` <span class="sw-win-text">& ${escapeHtml(parts[1])}</span>` : ''}` +
813
+ `</h1>` +
814
+ (o.headerSubtitle ? `<p class="sw-subtitle">${escapeHtml(o.headerSubtitle)}</p>` : '');
815
+ root.appendChild(header);
816
+ }
817
+
818
+ // ── wheel container ──
819
+ const wc = document.createElement('div');
820
+ wc.className = 'sw-wheel-container';
821
+
822
+ const wo = document.createElement('div');
823
+ wo.className = 'sw-wheel-outer';
824
+
825
+ const wi = document.createElement('div');
826
+ wi.className = 'sw-wheel-inner';
827
+
828
+ const wheel = document.createElement('div');
829
+ wheel.className = 'sw-wheel';
830
+ this._wheelEl = wheel;
831
+
832
+ this._buildSegments(wheel);
833
+
834
+ // hub
835
+ const hub = document.createElement('div');
836
+ hub.className = 'sw-wheel-hub';
837
+ hub.title = 'Spin the wheel';
838
+ hub.innerHTML =
839
+ `<span class="sw-wheel-hub-icon">${escapeHtml(o.hubIcon)}</span>` +
840
+ `<span class="sw-wheel-hub-brand">${escapeHtml(o.hubLabel)}</span>`;
841
+ this._hubEl = hub;
842
+
843
+ wi.append(wheel, hub);
844
+ wo.appendChild(wi);
845
+ wc.appendChild(wo);
846
+
847
+ // pointer
848
+ const pointer = document.createElement('div');
849
+ pointer.className = 'sw-pointer';
850
+ pointer.innerHTML = '<div class="sw-pointer-outer"></div>';
851
+ this._pointerEl = pointer;
852
+ wc.appendChild(pointer);
853
+
854
+ root.appendChild(wc);
855
+
856
+ // ── spin button ──
857
+ if (o.showButton) {
858
+ const btn = document.createElement('button');
859
+ btn.className = 'sw-spin-btn';
860
+ btn.type = 'button';
861
+ btn.textContent = o.buttonText;
862
+ this._btnEl = btn;
863
+ root.appendChild(btn);
864
+ }
865
+
866
+ this.containerEl.appendChild(root);
867
+ this._rootEl = root;
868
+
869
+ // ── overlay ──
870
+ if (o.showWinCard) {
871
+ this._buildOverlay();
872
+ }
873
+ }
874
+
875
+ /**
876
+ * Render pie-slice segments into the given wheel element.
877
+ * @param {HTMLElement} wheel
878
+ */
879
+ _buildSegments(wheel) {
880
+ const angle = this.segmentAngle;
881
+ const clip = buildClipPath(angle);
882
+ const rot = textRotation(angle);
883
+ const segments = this.options.segments;
884
+
885
+ for (let i = 0; i < segments.length; i++) {
886
+ const p = segments[i];
887
+ const seg = document.createElement('div');
888
+ seg.className = 'sw-segment';
889
+ seg.style.transform = `rotate(${i * angle}deg)`;
890
+ seg.style.clipPath = clip;
891
+ seg.style.webkitClipPath = clip;
892
+ seg.style.background = p.color || '#ccc';
893
+ seg.innerHTML =
894
+ `<div class="sw-segment-content" style="transform:rotate(${rot}deg)">` +
895
+ `<span class="sw-segment-icon">${escapeHtml(p.icon || '')}</span>` +
896
+ `<span class="sw-segment-label">${escapeHtml(p.label)}</span>` +
897
+ `</div>`;
898
+ wheel.appendChild(seg);
899
+ }
900
+ }
901
+
902
+ /** Build the win-card overlay and append it to <body>. */
903
+ _buildOverlay() {
904
+ const o = this.options;
905
+ const brandLabel = o.winCardBrandLabel || o.hubLabel;
906
+ const redeemText = o.winCardRedeemButtonText || 'Redeem Now';
907
+
908
+ const overlay = document.createElement('div');
909
+ overlay.className = 'sw-overlay';
910
+
911
+ const confetti = document.createElement('div');
912
+ confetti.className = 'sw-confetti';
913
+ this._confettiEl = confetti;
914
+
915
+ const card = document.createElement('div');
916
+ card.className = 'sw-win-card';
917
+ card.innerHTML =
918
+ `<div class="sw-win-card-top">` +
919
+ ` <div class="sw-win-card-product">🎁</div>` +
920
+ ` <div class="sw-win-card-brand">${escapeHtml(brandLabel)}</div>` +
921
+ `</div>` +
922
+ `<div class="sw-win-card-perf"></div>` +
923
+ `<div class="sw-win-card-mid">` +
924
+ ` <div class="sw-win-card-title">Prize</div>` +
925
+ ` <div class="sw-win-card-worth">${escapeHtml(o.winCardWorthLabel)} ₹99</div>` +
926
+ ` <div class="sw-win-card-code-wrap">` +
927
+ ` <input type="text" class="sw-win-card-code" readonly value="XXXXXXXXX" />` +
928
+ ` <button type="button" class="sw-win-card-copy" aria-label="Copy code">${COPY_ICON_SVG}</button>` +
929
+ ` </div>` +
930
+ `</div>` +
931
+ `<div class="sw-win-card-bot">` +
932
+ ` <span class="sw-win-card-redeem">${escapeHtml(redeemText)}</span>` +
933
+ ` ${ARROW_ICON_SVG}` +
934
+ `</div>`;
935
+
936
+ overlay.append(confetti, card);
937
+ this.containerEl.appendChild(overlay);
938
+
939
+ this._overlayEl = overlay;
940
+ this._cardProductEl = card.querySelector('.sw-win-card-product');
941
+ this._cardBrandEl = card.querySelector('.sw-win-card-brand');
942
+ this._cardTitleEl = card.querySelector('.sw-win-card-title');
943
+ this._cardWorthEl = card.querySelector('.sw-win-card-worth');
944
+ this._cardCodeEl = card.querySelector('.sw-win-card-code');
945
+ this._cardCopyEl = card.querySelector('.sw-win-card-copy');
946
+ this._cardRedeemEl = card.querySelector('.sw-win-card-bot');
947
+ this._cardRedeemTextEl = card.querySelector('.sw-win-card-redeem');
948
+ }
949
+
950
+ /* ================================================================ */
951
+ /* Event binding */
952
+ /* ================================================================ */
953
+
954
+ /** Wire up all interactive event listeners with proper references for cleanup. */
955
+ _bind() {
956
+ const bl = this._boundListeners;
957
+
958
+ // Spin button
959
+ if (this._btnEl) {
960
+ bl._btnClick = () => this.spin();
961
+ this._btnEl.addEventListener('click', bl._btnClick);
962
+ }
963
+
964
+ // Hub click + keyboard (Enter / Space)
965
+ if (this._hubEl) {
966
+ this._hubEl.setAttribute('tabindex', '0');
967
+ this._hubEl.setAttribute('role', 'button');
968
+ this._hubEl.setAttribute('aria-label', 'Spin the wheel');
969
+
970
+ bl._hubClick = (e) => { e.stopPropagation(); this.spin(); };
971
+ bl._hubKeydown = (e) => {
972
+ if (e.key === 'Enter' || e.key === ' ') {
973
+ e.preventDefault();
974
+ this.spin();
975
+ }
976
+ };
977
+ this._hubEl.addEventListener('click', bl._hubClick);
978
+ this._hubEl.addEventListener('keydown', bl._hubKeydown);
979
+ }
980
+
981
+ // Keyboard: Enter/Space on spin button
982
+ if (this._btnEl) {
983
+ bl._btnKeydown = (e) => {
984
+ if (e.key === 'Enter' || e.key === ' ') {
985
+ e.preventDefault();
986
+ this.spin();
987
+ }
988
+ };
989
+ this._btnEl.addEventListener('keydown', bl._btnKeydown);
990
+ }
991
+
992
+ // Overlay events
993
+ if (this._overlayEl) {
994
+ bl._overlayClick = (e) => {
995
+ if (e.target === this._overlayEl) this.hideWinCard();
996
+ };
997
+ this._overlayEl.addEventListener('click', bl._overlayClick);
998
+
999
+ bl._copyClick = (e) => {
1000
+ e.stopPropagation();
1001
+ const code = this._cardCodeEl.value;
1002
+ this._cardCodeEl.select();
1003
+ this._cardCodeEl.setSelectionRange(0, 999);
1004
+ if (navigator.clipboard?.writeText) {
1005
+ navigator.clipboard.writeText(code).then(() => this._showCopied());
1006
+ } else {
1007
+ try { document.execCommand('copy'); this._showCopied(); } catch (_) { /* noop */ }
1008
+ }
1009
+ };
1010
+ this._cardCopyEl.addEventListener('click', bl._copyClick);
1011
+
1012
+ bl._redeemClick = (e) => {
1013
+ e.stopPropagation();
1014
+ if (typeof this.options.onRedeem === 'function') {
1015
+ try { this.options.onRedeem(this._lastWonPrize); } catch (err) { console.error('[SpinWheel] onRedeem callback error:', err); }
1016
+ }
1017
+ if (this.options.redeemUrl) {
1018
+ window.open(this.options.redeemUrl, '_blank', 'noopener,noreferrer');
1019
+ }
1020
+ this.hideWinCard();
1021
+ };
1022
+ this._cardRedeemEl.addEventListener('click', bl._redeemClick);
1023
+ }
1024
+
1025
+ // Global keyboard: Escape closes win card
1026
+ bl._docKeydown = (e) => {
1027
+ if (e.key === 'Escape' && this._overlayEl?.classList.contains('sw-visible')) {
1028
+ this.hideWinCard();
1029
+ }
1030
+ };
1031
+ document.addEventListener('keydown', bl._docKeydown);
1032
+ }
1033
+
1034
+ /** Temporarily swap the copy button icon to a checkmark. */
1035
+ _showCopied() {
1036
+ if (!this._cardCopyEl || this._destroyed) return;
1037
+ this._cardCopyEl.innerHTML = CHECK_ICON_SVG;
1038
+ setTimeout(() => { if (this._cardCopyEl) this._cardCopyEl.innerHTML = COPY_ICON_SVG; }, 1500);
1039
+ }
1040
+
1041
+ /* ================================================================ */
1042
+ /* Public API */
1043
+ /* ================================================================ */
1044
+
1045
+ /**
1046
+ * Check if spin limit has been reached.
1047
+ * @returns {boolean}
1048
+ */
1049
+ _isSpinLimitReached() {
1050
+ const limit = this.options.spinLimit;
1051
+ return typeof limit === 'number' && limit > 0 && this._spinCount >= limit;
1052
+ }
1053
+
1054
+ /**
1055
+ * Spin the wheel.
1056
+ * Priority: forceIndex > options.winningIndex > random.
1057
+ * @param {number} [forceIndex] – optional index to force the result (overrides winningIndex)
1058
+ */
1059
+ spin(forceIndex) {
1060
+ if (this.isSpinning || this._destroyed) return;
1061
+
1062
+ // Check spin limit
1063
+ if (this._isSpinLimitReached()) {
1064
+ if (typeof this.options.onSpinLimitReached === 'function') {
1065
+ try { this.options.onSpinLimitReached(this._spinCount); } catch (err) { console.error('[SpinWheel] onSpinLimitReached callback error:', err); }
1066
+ }
1067
+ return;
1068
+ }
1069
+
1070
+ this.isSpinning = true;
1071
+ this._spinCount++;
1072
+ if (this._btnEl) this._btnEl.disabled = true;
1073
+ if (this._wheelEl) this._wheelEl.classList.add('sw-spinning');
1074
+
1075
+ const { segments, minSpins, maxSpins, spinDuration, winningIndex } = this.options;
1076
+ const count = segments.length;
1077
+ const segAngle = this.segmentAngle;
1078
+
1079
+ // Resolve winning index: forceIndex > winningIndex > random
1080
+ // Clamp forceIndex to valid range if provided
1081
+ const isValidIndex = (idx) => typeof idx === 'number' && !isNaN(idx) && idx >= 0 && idx < count;
1082
+
1083
+ let winIndex;
1084
+ if (forceIndex !== undefined && forceIndex !== null) {
1085
+ if (typeof forceIndex === 'number' && !isNaN(forceIndex)) {
1086
+ winIndex = Math.max(0, Math.min(count - 1, Math.floor(forceIndex)));
1087
+ } else {
1088
+ console.warn(`[SpinWheel] spin(forceIndex): invalid forceIndex "${forceIndex}". Using random.`);
1089
+ winIndex = Math.floor(Math.random() * count);
1090
+ }
1091
+ } else if (isValidIndex(winningIndex)) {
1092
+ winIndex = winningIndex;
1093
+ } else {
1094
+ winIndex = Math.floor(Math.random() * count);
1095
+ }
1096
+
1097
+ if (typeof this.options.onSpinStart === 'function') {
1098
+ try { this.options.onSpinStart(winIndex); } catch (err) { console.error('[SpinWheel] onSpinStart callback error:', err); }
1099
+ }
1100
+
1101
+ const segmentCenter = winIndex * segAngle + segAngle / 2;
1102
+ const fullSpins = (minSpins || 5) + Math.floor(Math.random() * ((maxSpins || 8) - (minSpins || 5) + 1));
1103
+ const pointerAt = 270;
1104
+ const remainder = (pointerAt - segmentCenter - (this.currentRotation % 360) + 720) % 360;
1105
+ const targetAngle = 360 * fullSpins + remainder;
1106
+ const totalRotation = this.currentRotation + targetAngle;
1107
+ const duration = spinDuration || 5000;
1108
+ const startTime = performance.now();
1109
+ const startRot = this.currentRotation;
1110
+
1111
+ const animate = (now) => {
1112
+ if (this._destroyed) return;
1113
+ const elapsed = now - startTime;
1114
+ const progress = Math.min(elapsed / duration, 1);
1115
+ const easeOut = 1 - Math.pow(1 - progress, 3);
1116
+ const rotation = startRot + targetAngle * easeOut;
1117
+ if (this._wheelEl) this._wheelEl.style.transform = `rotate(${rotation}deg)`;
1118
+
1119
+ if (progress < 1) {
1120
+ this._rafId = requestAnimationFrame(animate);
1121
+ } else {
1122
+ this.currentRotation = totalRotation % 360;
1123
+ if (this._wheelEl) {
1124
+ this._wheelEl.classList.remove('sw-spinning');
1125
+ this._wheelEl.style.transform = `rotate(${this.currentRotation}deg)`;
1126
+ }
1127
+ this._highlightPointer();
1128
+
1129
+ this._lastWonPrize = segments[winIndex];
1130
+ this._lastWonIndex = winIndex;
1131
+
1132
+ if (typeof this.options.onSpinEnd === 'function') {
1133
+ try { this.options.onSpinEnd(segments[winIndex], winIndex); } catch (err) { console.error('[SpinWheel] onSpinEnd callback error:', err); }
1134
+ }
1135
+ if (this.options.showWinCard) {
1136
+ this._showWinCard(segments[winIndex]);
1137
+ }
1138
+
1139
+ this.isSpinning = false;
1140
+
1141
+ // Re-enable button only if spin limit not reached
1142
+ if (this._btnEl) {
1143
+ this._btnEl.disabled = this._isSpinLimitReached();
1144
+ }
1145
+ }
1146
+ };
1147
+
1148
+ this._rafId = requestAnimationFrame(animate);
1149
+ }
1150
+
1151
+ /** Bounce the pointer for visual feedback. */
1152
+ _highlightPointer() {
1153
+ if (!this._pointerEl || this._destroyed) return;
1154
+ this._pointerEl.classList.remove('sw-highlight');
1155
+ void this._pointerEl.offsetHeight; // force reflow
1156
+ this._pointerEl.classList.add('sw-highlight');
1157
+ setTimeout(() => this._pointerEl.classList.remove('sw-highlight'), 500);
1158
+ }
1159
+
1160
+ /**
1161
+ * Populate and reveal the win-card overlay.
1162
+ * @param {SegmentConfig} prize
1163
+ */
1164
+ _showWinCard(prize) {
1165
+ if (!prize || this._destroyed) return;
1166
+ const o = this.options;
1167
+
1168
+ // Update product icon and background
1169
+ if (this._cardProductEl) {
1170
+ this._cardProductEl.textContent = prize.icon || '🎁';
1171
+ this._cardProductEl.style.background =
1172
+ `linear-gradient(145deg,${prize.color || '#252530'}22,${prize.color || '#1c1c24'}11)`;
1173
+ }
1174
+
1175
+ // Update title
1176
+ if (this._cardTitleEl) {
1177
+ this._cardTitleEl.textContent = prize.title || prize.label || '';
1178
+ }
1179
+
1180
+ // Update worth label with configurable prefix
1181
+ if (this._cardWorthEl) {
1182
+ const worthLabel = o.winCardWorthLabel || 'WORTH';
1183
+ this._cardWorthEl.textContent = prize.worth ? `${worthLabel} ${prize.worth}` : '';
1184
+ this._cardWorthEl.style.display = prize.worth ? '' : 'none';
1185
+ }
1186
+
1187
+ // Handle code generation/display
1188
+ if (this._cardCodeEl) {
1189
+ if (o.generateCode) {
1190
+ this._cardCodeEl.value = prize.code || randomCode(o.codeLength);
1191
+ if (this._cardCodeEl.parentElement) this._cardCodeEl.parentElement.style.display = '';
1192
+ } else if (prize.code) {
1193
+ this._cardCodeEl.value = prize.code;
1194
+ if (this._cardCodeEl.parentElement) this._cardCodeEl.parentElement.style.display = '';
1195
+ } else {
1196
+ if (this._cardCodeEl.parentElement) this._cardCodeEl.parentElement.style.display = 'none';
1197
+ }
1198
+ }
1199
+
1200
+ // Update redeem button text if configured
1201
+ if (this._cardRedeemTextEl && o.winCardRedeemButtonText) {
1202
+ this._cardRedeemTextEl.textContent = o.winCardRedeemButtonText;
1203
+ }
1204
+
1205
+ // Create confetti only if enabled (and no reduced motion preference)
1206
+ if (o.confettiOnWin !== false && !_prefersReducedMotion$1()) {
1207
+ this._createConfetti();
1208
+ }
1209
+
1210
+ if (this._overlayEl) this._overlayEl.classList.add('sw-visible');
1211
+
1212
+ // A11y: announce the win to screen readers
1213
+ this._announceToScreenReader(`You won ${prize.title || prize.label || 'a prize'}${prize.worth ? ', worth ' + prize.worth : ''}.`);
1214
+ }
1215
+
1216
+ /** Programmatically close the win-card overlay. */
1217
+ hideWinCard() {
1218
+ if (this._overlayEl) this._overlayEl.classList.remove('sw-visible');
1219
+ }
1220
+
1221
+ /** Generate confetti particles inside the overlay. */
1222
+ _createConfetti() {
1223
+ if (!this._confettiEl || this._destroyed) return;
1224
+ const o = this.options;
1225
+ const colors = o.confettiColors || CONFETTI_COLORS;
1226
+ const count = o.confettiCount || 40;
1227
+
1228
+ this._confettiEl.innerHTML = '';
1229
+ for (let i = 0; i < count; i++) {
1230
+ const span = document.createElement('span');
1231
+ span.style.left = `${Math.random() * 100}%`;
1232
+ span.style.top = '-10px';
1233
+ span.style.background = colors[Math.floor(Math.random() * colors.length)];
1234
+ span.style.animationDelay = `${Math.random() * 0.3}s`;
1235
+ span.style.animationDuration = `${0.8 + Math.random() * 0.6}s`;
1236
+ this._confettiEl.appendChild(span);
1237
+ }
1238
+ }
1239
+
1240
+ /**
1241
+ * Replace all segments at runtime and re-render the wheel.
1242
+ * @param {SegmentConfig[]} newSegments
1243
+ */
1244
+ updateSegments(newSegments) {
1245
+ if (this._destroyed) return;
1246
+ if (!Array.isArray(newSegments) || newSegments.length < 2) {
1247
+ console.warn('[SpinWheel] updateSegments: at least 2 segments are required. Ignoring.');
1248
+ return;
1249
+ }
1250
+ this.options.segments = newSegments;
1251
+ this.segmentAngle = 360 / newSegments.length;
1252
+ if (this._wheelEl) {
1253
+ this._wheelEl.innerHTML = '';
1254
+ this._buildSegments(this._wheelEl);
1255
+ }
1256
+ this.currentRotation = 0;
1257
+ if (this._wheelEl) this._wheelEl.style.transform = 'rotate(0deg)';
1258
+ }
1259
+
1260
+ /**
1261
+ * Remove all SDK-created DOM, event listeners, and animation frames.
1262
+ * Safe to call multiple times.
1263
+ */
1264
+ destroy() {
1265
+ if (this._destroyed) return;
1266
+ this._destroyed = true;
1267
+
1268
+ // ── Cancel pending animation frame ──
1269
+ if (this._rafId) {
1270
+ cancelAnimationFrame(this._rafId);
1271
+ this._rafId = null;
1272
+ }
1273
+
1274
+ // ── Remove all event listeners ──
1275
+ const bl = this._boundListeners;
1276
+ if (bl._btnClick && this._btnEl) {
1277
+ this._btnEl.removeEventListener('click', bl._btnClick);
1278
+ this._btnEl.removeEventListener('keydown', bl._btnKeydown);
1279
+ }
1280
+ if (bl._hubClick && this._hubEl) {
1281
+ this._hubEl.removeEventListener('click', bl._hubClick);
1282
+ this._hubEl.removeEventListener('keydown', bl._hubKeydown);
1283
+ }
1284
+ if (bl._overlayClick && this._overlayEl) {
1285
+ this._overlayEl.removeEventListener('click', bl._overlayClick);
1286
+ }
1287
+ if (bl._copyClick && this._cardCopyEl) {
1288
+ this._cardCopyEl.removeEventListener('click', bl._copyClick);
1289
+ }
1290
+ if (bl._redeemClick && this._cardRedeemEl) {
1291
+ this._cardRedeemEl.removeEventListener('click', bl._redeemClick);
1292
+ }
1293
+ if (bl._docKeydown) {
1294
+ document.removeEventListener('keydown', bl._docKeydown);
1295
+ }
1296
+ this._boundListeners = {};
1297
+
1298
+ // ── Remove live-region ──
1299
+ if (this._liveRegion?.parentNode) {
1300
+ this._liveRegion.parentNode.removeChild(this._liveRegion);
1301
+ }
1302
+
1303
+ // ── Remove DOM ──
1304
+ this._rootEl?.parentNode?.removeChild(this._rootEl);
1305
+ this._overlayEl?.parentNode?.removeChild(this._overlayEl);
1306
+
1307
+ // Clear all DOM references to prevent memory leaks
1308
+ this._rootEl = null;
1309
+ this._wheelEl = null;
1310
+ this._hubEl = null;
1311
+ this._pointerEl = null;
1312
+ this._btnEl = null;
1313
+ this._overlayEl = null;
1314
+ this._confettiEl = null;
1315
+ this._cardProductEl = null;
1316
+ this._cardBrandEl = null;
1317
+ this._cardTitleEl = null;
1318
+ this._cardWorthEl = null;
1319
+ this._cardCodeEl = null;
1320
+ this._cardCopyEl = null;
1321
+ this._cardRedeemEl = null;
1322
+ this._cardRedeemTextEl = null;
1323
+ this._liveRegion = null;
1324
+ }
1325
+
1326
+ /** The last prize that was won. */
1327
+ get lastWonPrize() {
1328
+ return this._lastWonPrize;
1329
+ }
1330
+
1331
+ /** The last won segment index. */
1332
+ get lastWonIndex() {
1333
+ return this._lastWonIndex;
1334
+ }
1335
+
1336
+ /** The current spin count. */
1337
+ get spinCount() {
1338
+ return this._spinCount;
1339
+ }
1340
+
1341
+ /** The remaining spins (null if unlimited). */
1342
+ get remainingSpins() {
1343
+ const limit = this.options.spinLimit;
1344
+ if (typeof limit !== 'number' || limit <= 0) return null;
1345
+ return Math.max(0, limit - this._spinCount);
1346
+ }
1347
+
1348
+ /**
1349
+ * Reset the spin count to allow more spins.
1350
+ * @param {number} [count=0] - New spin count to set
1351
+ */
1352
+ resetSpinCount(count = 0) {
1353
+ this._spinCount = Math.max(0, typeof count === 'number' && !isNaN(count) ? Math.floor(count) : 0);
1354
+ if (this._btnEl) {
1355
+ this._btnEl.disabled = this._isSpinLimitReached();
1356
+ }
1357
+ }
1358
+
1359
+ /**
1360
+ * Announce a message to screen readers via a visually hidden aria-live region.
1361
+ * @param {string} message
1362
+ */
1363
+ _announceToScreenReader(message) {
1364
+ if (!_isBrowser$1) return;
1365
+ if (!this._liveRegion) {
1366
+ const el = document.createElement('div');
1367
+ el.setAttribute('aria-live', 'assertive');
1368
+ el.setAttribute('aria-atomic', 'true');
1369
+ el.setAttribute('role', 'status');
1370
+ el.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
1371
+ document.body.appendChild(el);
1372
+ this._liveRegion = el;
1373
+ }
1374
+ this._liveRegion.textContent = '';
1375
+ // Slight delay so screen readers pick up the change
1376
+ setTimeout(() => { if (this._liveRegion) this._liveRegion.textContent = message; }, 100);
1377
+ }
1378
+
1379
+ /**
1380
+ * Update configuration options at runtime.
1381
+ * @param {Partial<SpinWheelOptions>} newOptions
1382
+ */
1383
+ updateOptions(newOptions) {
1384
+ if (this._destroyed) return;
1385
+ if (!newOptions || typeof newOptions !== 'object') {
1386
+ console.warn('[SpinWheel] updateOptions: argument must be an object. Ignoring.');
1387
+ return;
1388
+ }
1389
+
1390
+ // Sanitize numeric/enum values before merging
1391
+ const merged = { ...this.options, ...newOptions, container: this.options.container, segments: this.options.segments };
1392
+ _validateOptions$1(merged);
1393
+ // Apply sanitized values back
1394
+ for (const key of Object.keys(newOptions)) {
1395
+ if (key in merged) newOptions[key] = merged[key];
1396
+ }
1397
+
1398
+ // Merge new options
1399
+ this.options = deepMerge({}, this.options, newOptions);
1400
+
1401
+ // Handle theme separately for proper nested merge
1402
+ if (newOptions.theme) {
1403
+ this.options.theme = { ...DEFAULTS$1.theme, ...this.options.theme, ...newOptions.theme };
1404
+ }
1405
+
1406
+ // Re-inject CSS if styling options changed
1407
+ injectCSS$1(this.options);
1408
+
1409
+ // Update button text if changed
1410
+ if (this._btnEl && newOptions.buttonText !== undefined) {
1411
+ this._btnEl.textContent = this.options.buttonText;
1412
+ }
1413
+
1414
+ // Update hub if changed
1415
+ if (this._hubEl) {
1416
+ if (newOptions.hubLabel !== undefined || newOptions.hubIcon !== undefined) {
1417
+ this._hubEl.innerHTML =
1418
+ `<span class="sw-wheel-hub-icon">${escapeHtml(this.options.hubIcon)}</span>` +
1419
+ `<span class="sw-wheel-hub-brand">${escapeHtml(this.options.hubLabel)}</span>`;
1420
+ }
1421
+ }
1422
+
1423
+ // Update win card brand label if changed
1424
+ if (this._cardBrandEl && (newOptions.winCardBrandLabel !== undefined || newOptions.hubLabel !== undefined)) {
1425
+ const brandLabel = this.options.winCardBrandLabel || this.options.hubLabel;
1426
+ this._cardBrandEl.textContent = brandLabel;
1427
+ }
1428
+
1429
+ // Update redeem button text if changed
1430
+ if (this._cardRedeemTextEl && newOptions.winCardRedeemButtonText !== undefined) {
1431
+ this._cardRedeemTextEl.textContent = this.options.winCardRedeemButtonText;
1432
+ }
1433
+ }
1434
+ }
1435
+
1436
+ /* ------------------------------------------------------------------ */
1437
+ /* Public factory (namespace) */
1438
+ /* ------------------------------------------------------------------ */
1439
+
1440
+ /**
1441
+ * Static factory to create a SpinWheel instance.
1442
+ *
1443
+ * @example
1444
+ * const wheel = SpinWheel.init({ container: '#app', segments: [...] });
1445
+ */
1446
+ class SpinWheel {
1447
+ /**
1448
+ * Initialise and mount a new Spin Wheel.
1449
+ * @param {SpinWheelOptions} options
1450
+ * @returns {SpinWheelInstance}
1451
+ */
1452
+ static init(options) {
1453
+ return new SpinWheelInstance(options);
1454
+ }
1455
+
1456
+ /** The underlying class – useful for `instanceof` checks or subclassing. */
1457
+ static Instance = SpinWheelInstance;
1458
+
1459
+ /** Current SDK version – kept in sync with package.json via build banner */
1460
+ static VERSION = '0.1.0-alpha.1';
1461
+
1462
+ /** Export defaults for reference */
1463
+ static DEFAULTS = DEFAULTS$1;
1464
+ }
1465
+
1466
+ /**
1467
+ * SpinWheel React Component (ES6 class-based wrapper)
1468
+ *
1469
+ * @example
1470
+ * import { SpinWheelReact } from 'spin-wheel-sdk/react';
1471
+ *
1472
+ * <SpinWheelReact
1473
+ * ref={wheelRef}
1474
+ * segments={[...]}
1475
+ * onSpinEnd={(prize, idx) => console.log(prize)}
1476
+ * />
1477
+ */
1478
+
1479
+
1480
+ /**
1481
+ * All supported prop keys for SpinWheelReact component.
1482
+ * Used for copying props to SDK options.
1483
+ */
1484
+ const PROP_KEYS$1 = Object.freeze([
1485
+ // Core
1486
+ 'segments',
1487
+ 'winningIndex',
1488
+
1489
+ // Spin Behavior
1490
+ 'spinDuration',
1491
+ 'minSpins',
1492
+ 'maxSpins',
1493
+ 'spinLimit',
1494
+
1495
+ // Header UI
1496
+ 'headerTitle',
1497
+ 'headerSubtitle',
1498
+ 'titleColor',
1499
+ 'subtitleColor',
1500
+
1501
+ // Hub
1502
+ 'hubLabel',
1503
+ 'hubIcon',
1504
+
1505
+ // Spin Button
1506
+ 'showButton',
1507
+ 'buttonText',
1508
+
1509
+ // Wheel Appearance
1510
+ 'backgroundColor',
1511
+ 'backgroundImage',
1512
+ 'ringColor',
1513
+ 'ringShadow',
1514
+ 'ringAnimation',
1515
+ 'pointerColor',
1516
+ 'buttonColor',
1517
+ 'buttonShadow',
1518
+ 'buttonAnimation',
1519
+
1520
+ // Win Card
1521
+ 'showWinCard',
1522
+ 'generateCode',
1523
+ 'codeLength',
1524
+ 'redeemUrl',
1525
+ 'winCardBrandLabel',
1526
+ 'winCardWorthLabel',
1527
+ 'winCardRedeemButtonText',
1528
+ 'winCardRedeemButtonColorTop',
1529
+ 'winCardRedeemButtonColorBottom',
1530
+ 'winCardRedeemButtonTextColor',
1531
+
1532
+ // Confetti
1533
+ 'confettiOnWin',
1534
+ 'confettiColors',
1535
+ 'confettiCount',
1536
+
1537
+ // Theme
1538
+ 'theme',
1539
+ ]);
1540
+
1541
+ /**
1542
+ * Creates the SpinWheelReact component using the provided React instance.
1543
+ * This factory pattern avoids bundling React into the SDK.
1544
+ *
1545
+ * @param {typeof import('react')} React – the React module
1546
+ * @returns {{ SpinWheelReact: React.ForwardRefExoticComponent }}
1547
+ */
1548
+ function createSpinWheelReact(React) {
1549
+ const { useEffect, useRef, useImperativeHandle, forwardRef, createElement } = React;
1550
+
1551
+ const SpinWheelReact = forwardRef(function SpinWheelReact(props, ref) {
1552
+ const containerRef = useRef(null);
1553
+ const instanceRef = useRef(null);
1554
+
1555
+ // Keep latest callbacks in a ref so they're always fresh
1556
+ const callbacksRef = useRef({});
1557
+ callbacksRef.current.onSpinStart = props.onSpinStart;
1558
+ callbacksRef.current.onSpinEnd = props.onSpinEnd;
1559
+ callbacksRef.current.onRedeem = props.onRedeem;
1560
+ callbacksRef.current.onSpinLimitReached = props.onSpinLimitReached;
1561
+
1562
+ // Mount the SDK once, destroy on unmount (SSR-safe)
1563
+ useEffect(() => {
1564
+ if (!containerRef.current || typeof window === 'undefined') return;
1565
+
1566
+ const opts = {};
1567
+
1568
+ // Copy only defined props
1569
+ PROP_KEYS$1.forEach((k) => {
1570
+ if (props[k] !== undefined) opts[k] = props[k];
1571
+ });
1572
+
1573
+ opts.container = containerRef.current;
1574
+
1575
+ // Wrap callbacks so they always point to latest prop
1576
+ opts.onSpinStart = (idx) => callbacksRef.current.onSpinStart?.(idx);
1577
+ opts.onSpinEnd = (prize, idx) => callbacksRef.current.onSpinEnd?.(prize, idx);
1578
+ opts.onRedeem = (prize) => callbacksRef.current.onRedeem?.(prize);
1579
+ opts.onSpinLimitReached = (count) => callbacksRef.current.onSpinLimitReached?.(count);
1580
+
1581
+ try {
1582
+ instanceRef.current = SpinWheel.init(opts);
1583
+ } catch (err) {
1584
+ console.error('[SpinWheelReact] Failed to initialize SpinWheel:', err);
1585
+ }
1586
+
1587
+ return () => {
1588
+ try {
1589
+ instanceRef.current?.destroy();
1590
+ } catch (_) { /* noop */ }
1591
+ instanceRef.current = null;
1592
+ };
1593
+ }, []); // mount-only
1594
+
1595
+ // Live-update segments when the prop changes
1596
+ useEffect(() => {
1597
+ if (instanceRef.current && props.segments) {
1598
+ try {
1599
+ instanceRef.current.updateSegments(props.segments);
1600
+ } catch (_) { /* ignore on first mount */ }
1601
+ }
1602
+ }, [props.segments]);
1603
+
1604
+ // Expose imperative handle
1605
+ useImperativeHandle(ref, () => ({
1606
+ /** Trigger a spin (optionally force the winning index) */
1607
+ spin(forceIndex) { instanceRef.current?.spin(forceIndex); },
1608
+
1609
+ /** Replace segments at runtime */
1610
+ updateSegments(segs) { instanceRef.current?.updateSegments(segs); },
1611
+
1612
+ /** Close the win-card overlay */
1613
+ hideWinCard() { instanceRef.current?.hideWinCard(); },
1614
+
1615
+ /** Update options at runtime */
1616
+ updateOptions(opts) { instanceRef.current?.updateOptions(opts); },
1617
+
1618
+ /** Reset the spin count */
1619
+ resetSpinCount(count) { instanceRef.current?.resetSpinCount(count); },
1620
+
1621
+ /** Get the current spin count */
1622
+ get spinCount() { return instanceRef.current?.spinCount; },
1623
+
1624
+ /** Get remaining spins (null if unlimited) */
1625
+ get remainingSpins() { return instanceRef.current?.remainingSpins; },
1626
+
1627
+ /** Get the last won prize */
1628
+ get lastWonPrize() { return instanceRef.current?.lastWonPrize; },
1629
+
1630
+ /** Get the last won index */
1631
+ get lastWonIndex() { return instanceRef.current?.lastWonIndex; },
1632
+
1633
+ /** Access the raw SpinWheelInstance */
1634
+ get instance() { return instanceRef.current; },
1635
+ }), []);
1636
+
1637
+ return createElement('div', {
1638
+ ref: containerRef,
1639
+ className: props.className || '',
1640
+ style: props.style || {},
1641
+ });
1642
+ });
1643
+
1644
+ SpinWheelReact.displayName = 'SpinWheelReact';
1645
+ return SpinWheelReact;
1646
+ }
1647
+
1648
+ /* ------------------------------------------------------------------
1649
+ Auto-detect React on `window` for UMD / CDN usage.
1650
+ In bundled ESM builds consumers call createSpinWheelReact(React).
1651
+ ------------------------------------------------------------------ */
1652
+ exports.SpinWheelReact = null;
1653
+
1654
+ if (typeof window !== 'undefined' && window.React) {
1655
+ exports.SpinWheelReact = createSpinWheelReact(window.React);
1656
+ }
1657
+
1658
+ /**
1659
+ * Generates the ScratchCard SDK stylesheet as a string, interpolated with theme colors.
1660
+ * @param {object} config - Style configuration object
1661
+ * @param {object} config.theme - Theme colors
1662
+ * @param {string} [config.headerTitleColor] - Custom header title color
1663
+ * @param {string} [config.instructionColor] - Custom instruction color
1664
+ * @param {string} [config.coinGradientStart] - Coin gradient start color
1665
+ * @param {string} [config.coinGradientEnd] - Coin gradient end color
1666
+ * @param {string} [config.cardBackground] - Card background gradient
1667
+ * @param {boolean} [config.cardShadow] - Enable card shadow
1668
+ * @param {number} [config.cardBorderRadius] - Card border radius
1669
+ * @param {string} [config.scratchZoneBackground] - Scratch zone background
1670
+ * @param {boolean} [config.scratchZoneShadow] - Enable scratch zone shadow
1671
+ * @param {number} [config.scratchZoneBorderRadius] - Scratch zone border radius
1672
+ * @param {string} [config.prizeTextColor] - Prize text color
1673
+ * @param {string} [config.prizeNameColor] - Prize name color
1674
+ * @param {string} [config.prizeIconBackground] - Prize icon background
1675
+ * @param {string} [config.giftIconBackground] - Gift icon background
1676
+ * @param {string} [config.modalTitleColor] - Modal title color
1677
+ * @param {string} [config.modalButtonColor] - Modal button gradient
1678
+ * @param {string} [config.modalButtonTextColor] - Modal button text color
1679
+ * @param {boolean} [config.modalBackdropBlur] - Enable modal backdrop blur
1680
+ * @param {string} [config.animationType] - Animation type
1681
+ * @param {number} [config.animationDuration] - Animation duration
1682
+ * @param {function} hexToRGBA
1683
+ * @returns {string}
1684
+ */
1685
+ function buildScratchCSS(config, hexToRGBA) {
1686
+ const t = config.theme || {};
1687
+
1688
+ // Extract theme colors with fallbacks
1689
+ const purpleDark = t.purpleDark || '#4a2c6a';
1690
+ const purpleMid = t.purpleMid || '#6b4a8a';
1691
+ const purpleLight = t.purpleLight || '#8b6baa';
1692
+ const gold = t.gold || '#d4a84b';
1693
+ const goldLight = t.goldLight || '#e8c547';
1694
+ const goldDark = t.goldDark || '#b8923a';
1695
+ const white = t.white || '#ffffff';
1696
+ const textDark = t.textDark || '#2d2d2d';
1697
+ const textMuted = t.textMuted || '#6b6b6b';
1698
+
1699
+ // Configurable colors
1700
+ const headerTitleColor = config.headerTitleColor || textDark;
1701
+ const instructionColor = config.instructionColor || textMuted;
1702
+ const coinStart = config.coinGradientStart || goldLight;
1703
+ const coinEnd = config.coinGradientEnd || goldDark;
1704
+ const prizeTextColor = config.prizeTextColor || 'rgba(255,255,255,0.95)';
1705
+ const prizeNameColor = config.prizeNameColor || goldLight;
1706
+ const prizeIconBg = config.prizeIconBackground || 'rgba(255,255,255,0.15)';
1707
+ const giftIconBg = config.giftIconBackground || 'rgba(255,255,255,0.25)';
1708
+ const modalTitleColor = config.modalTitleColor || textDark;
1709
+ const modalBtnText = config.modalButtonTextColor || white;
1710
+
1711
+ // Feature toggles
1712
+ const cardShadow = config.cardShadow !== false;
1713
+ const scratchZoneShadow = config.scratchZoneShadow !== false;
1714
+ const modalBackdropBlur = config.modalBackdropBlur !== false;
1715
+
1716
+ // Dimension configs
1717
+ const cardBorderRadius = config.cardBorderRadius || 24;
1718
+ const scratchZoneBorderRadius = config.scratchZoneBorderRadius || 20;
1719
+ const animDuration = config.animationDuration || 600;
1720
+ const animType = config.animationType || 'default';
1721
+
1722
+ // Background configs
1723
+ const cardBg = config.cardBackground || `linear-gradient(180deg, #faf8fc 0%, #f0ebf5 100%)`;
1724
+ const scratchZoneBg = config.scratchZoneBackground || `linear-gradient(145deg, ${purpleDark} 0%, ${purpleMid} 50%, #5a3a7a 100%)`;
1725
+ const modalBtnBg = config.modalButtonColor || `linear-gradient(145deg, ${purpleMid} 0%, ${purpleDark} 100%)`;
1726
+
1727
+ // Computed values
1728
+ hexToRGBA(gold, 0.4);
1729
+
1730
+ // Card shadow styles
1731
+ const cardBoxShadow = cardShadow
1732
+ ? `box-shadow:0 4px 24px ${hexToRGBA(purpleDark, 0.12)}, 0 12px 48px rgba(0,0,0,0.08);`
1733
+ : '';
1734
+
1735
+ // Scratch zone shadow styles
1736
+ const zoneShadow = scratchZoneShadow
1737
+ ? `box-shadow:inset 0 2px 20px rgba(0,0,0,0.2), 0 8px 24px ${hexToRGBA(purpleDark, 0.35)};`
1738
+ : '';
1739
+
1740
+ // Modal backdrop blur
1741
+ const backdropBlur = modalBackdropBlur ? 'backdrop-filter:blur(4px);' : '';
1742
+
1743
+ // Animation styles based on type
1744
+ const cardAnimation = animType === 'none' ? '' :
1745
+ animType === 'bounce'
1746
+ ? `animation:sc-card-bounce ${animDuration}ms cubic-bezier(0.68, -0.55, 0.265, 1.55) forwards;`
1747
+ : `animation:sc-card-slide ${animDuration}ms cubic-bezier(0.34, 1.56, 0.64, 1) forwards;`;
1748
+
1749
+ const modalAnimation = animType === 'none' ? '' :
1750
+ `transition:transform ${animDuration * 0.66}ms cubic-bezier(0.34, 1.56, 0.64, 1);`;
1751
+
1752
+ return `
1753
+ /* === ScratchCard SDK v1.1 === */
1754
+ @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@400;500;600;700&display=swap');
1755
+
1756
+ .sc-root *, .sc-modal-overlay * { margin:0; padding:0; box-sizing:border-box; }
1757
+
1758
+ .sc-root {
1759
+ font-family:'Outfit',sans-serif;
1760
+ display:flex;
1761
+ align-items:center;
1762
+ justify-content:center;
1763
+ padding:2rem 1rem;
1764
+ min-height:100%;
1765
+ background:linear-gradient(135deg, #e8e0f0 0%, #d8d0e8 50%, #e0d8f0 100%);
1766
+ position:relative;
1767
+ overflow:hidden;
1768
+ }
1769
+
1770
+ .sc-page-bg {
1771
+ position:fixed;
1772
+ inset:0;
1773
+ background:radial-gradient(ellipse 80% 70% at 50% 40%, ${hexToRGBA(purpleMid, 0.15)} 0%, transparent 60%);
1774
+ pointer-events:none;
1775
+ animation:sc-glow-pulse 4s ease-in-out infinite;
1776
+ }
1777
+
1778
+ @keyframes sc-glow-pulse {
1779
+ 0%, 100% { opacity:1; }
1780
+ 50% { opacity:0.7; }
1781
+ }
1782
+
1783
+ .sc-wrapper {
1784
+ position:relative;
1785
+ width:min(360px, 92vw);
1786
+ ${cardAnimation}
1787
+ }
1788
+
1789
+ @keyframes sc-card-slide {
1790
+ from { opacity:0; transform:translateY(24px) scale(0.96); }
1791
+ to { opacity:1; transform:translateY(0) scale(1); }
1792
+ }
1793
+
1794
+ @keyframes sc-card-bounce {
1795
+ 0% { opacity:0; transform:scale(0.5); }
1796
+ 60% { opacity:1; transform:scale(1.05); }
1797
+ 80% { transform:scale(0.98); }
1798
+ 100% { transform:scale(1); }
1799
+ }
1800
+
1801
+ .sc-main-card {
1802
+ background:${cardBg};
1803
+ border-radius:${cardBorderRadius}px;
1804
+ padding:28px 24px 32px;
1805
+ ${cardBoxShadow}
1806
+ }
1807
+
1808
+ .sc-header {
1809
+ text-align:center;
1810
+ margin-bottom:20px;
1811
+ }
1812
+
1813
+ .sc-header h1 {
1814
+ font-size:1.5rem;
1815
+ font-weight:700;
1816
+ color:${headerTitleColor};
1817
+ letter-spacing:-0.02em;
1818
+ }
1819
+
1820
+ .sc-zone {
1821
+ position:relative;
1822
+ width:100%;
1823
+ aspect-ratio:1.05;
1824
+ max-height:320px;
1825
+ border-radius:${scratchZoneBorderRadius}px;
1826
+ overflow:hidden;
1827
+ background:${scratchZoneBg};
1828
+ ${zoneShadow}
1829
+ }
1830
+
1831
+ .sc-zone:not(.sc-ready) .sc-prize-content {
1832
+ visibility:hidden;
1833
+ }
1834
+
1835
+ .sc-prize-content {
1836
+ position:absolute;
1837
+ inset:0;
1838
+ z-index:0;
1839
+ display:flex;
1840
+ flex-direction:column;
1841
+ align-items:center;
1842
+ justify-content:center;
1843
+ padding:20px;
1844
+ background:
1845
+ radial-gradient(circle at 30% 30%, rgba(255,255,255,0.08) 0%, transparent 45%),
1846
+ linear-gradient(145deg, ${purpleDark} 0%, ${purpleMid} 100%);
1847
+ }
1848
+
1849
+ .sc-prize-content::before {
1850
+ content:'';
1851
+ position:absolute;
1852
+ inset:0;
1853
+ background-image:
1854
+ url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M30 5 L35 25 L55 30 L35 35 L30 55 L25 35 L5 30 L25 25 Z' fill='none' stroke='rgba(255,255,255,0.06)' stroke-width='0.5'/%3E%3C/svg%3E"),
1855
+ url("data:image/svg+xml,%3Csvg width='40' height='40' viewBox='0 0 40 40' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='20' cy='20' r='1' fill='rgba(255,255,255,0.1)'/%3E%3C/svg%3E");
1856
+ opacity:0.9;
1857
+ pointer-events:none;
1858
+ }
1859
+
1860
+ .sc-prize-text {
1861
+ position:relative;
1862
+ z-index:1;
1863
+ text-align:center;
1864
+ color:${prizeTextColor};
1865
+ font-size:1.25rem;
1866
+ font-weight:700;
1867
+ text-shadow:0 2px 8px rgba(0,0,0,0.3);
1868
+ margin-bottom:8px;
1869
+ }
1870
+
1871
+ .sc-prize-name {
1872
+ font-size:1.5rem;
1873
+ font-weight:700;
1874
+ color:${prizeNameColor};
1875
+ text-shadow:0 0 20px ${hexToRGBA(gold, 0.5)};
1876
+ }
1877
+
1878
+ .sc-prize-icon {
1879
+ width:80px;
1880
+ height:80px;
1881
+ margin:12px auto;
1882
+ background:${prizeIconBg};
1883
+ border-radius:50%;
1884
+ display:flex;
1885
+ align-items:center;
1886
+ justify-content:center;
1887
+ font-size:2.5rem;
1888
+ }
1889
+
1890
+ .sc-prize-icon-visible {
1891
+ position:absolute;
1892
+ left:50%;
1893
+ top:50%;
1894
+ margin-left:-40px;
1895
+ margin-top:-40px;
1896
+ width:80px;
1897
+ height:80px;
1898
+ background:${giftIconBg};
1899
+ border-radius:50%;
1900
+ display:flex;
1901
+ align-items:center;
1902
+ justify-content:center;
1903
+ font-size:2.5rem;
1904
+ z-index:11;
1905
+ pointer-events:none;
1906
+ box-shadow:0 4px 20px rgba(0,0,0,0.15);
1907
+ }
1908
+
1909
+ .sc-coin {
1910
+ position:absolute;
1911
+ left:0;
1912
+ top:0;
1913
+ width:56px;
1914
+ height:56px;
1915
+ border-radius:50%;
1916
+ background:linear-gradient(145deg, ${coinStart} 0%, ${gold} 30%, ${coinEnd} 70%, #9a7a2a 100%);
1917
+ box-shadow:
1918
+ inset 0 2px 4px rgba(255,255,255,0.4),
1919
+ inset 0 -2px 6px rgba(0,0,0,0.25),
1920
+ 0 6px 16px rgba(0,0,0,0.3);
1921
+ display:flex;
1922
+ align-items:center;
1923
+ justify-content:center;
1924
+ font-weight:800;
1925
+ font-size:1.5rem;
1926
+ color:${coinEnd};
1927
+ text-shadow:0 1px 0 rgba(255,255,255,0.4);
1928
+ z-index:12;
1929
+ pointer-events:auto;
1930
+ cursor:grab;
1931
+ touch-action:none;
1932
+ transition:box-shadow 0.15s ease;
1933
+ }
1934
+
1935
+ .sc-coin:active {
1936
+ cursor:grabbing;
1937
+ box-shadow:
1938
+ inset 0 2px 4px rgba(255,255,255,0.3),
1939
+ 0 4px 12px rgba(0,0,0,0.35);
1940
+ }
1941
+
1942
+ .sc-coin.dragging {
1943
+ animation:none;
1944
+ }
1945
+
1946
+ .sc-canvas {
1947
+ position:absolute;
1948
+ top:0;
1949
+ left:0;
1950
+ right:0;
1951
+ bottom:0;
1952
+ width:100%;
1953
+ height:100%;
1954
+ z-index:10;
1955
+ display:block;
1956
+ cursor:crosshair;
1957
+ touch-action:none;
1958
+ }
1959
+
1960
+ .sc-instruction {
1961
+ text-align:center;
1962
+ margin-top:18px;
1963
+ font-size:0.95rem;
1964
+ color:${instructionColor};
1965
+ font-weight:500;
1966
+ }
1967
+
1968
+ /* Modal */
1969
+ .sc-modal-overlay {
1970
+ position:fixed;
1971
+ inset:0;
1972
+ background:rgba(0,0,0,0.5);
1973
+ ${backdropBlur}
1974
+ display:flex;
1975
+ align-items:center;
1976
+ justify-content:center;
1977
+ padding:1.5rem;
1978
+ z-index:100;
1979
+ opacity:0;
1980
+ visibility:hidden;
1981
+ transition:opacity 0.35s ease, visibility 0.35s ease;
1982
+ }
1983
+
1984
+ .sc-modal-overlay.active {
1985
+ opacity:1;
1986
+ visibility:visible;
1987
+ }
1988
+
1989
+ .sc-confetti-canvas {
1990
+ position:absolute;
1991
+ inset:0;
1992
+ width:100%;
1993
+ height:100%;
1994
+ pointer-events:none;
1995
+ z-index:1;
1996
+ }
1997
+
1998
+ .sc-modal-bg-glow {
1999
+ position:absolute;
2000
+ inset:0;
2001
+ z-index:2;
2002
+ background:radial-gradient(
2003
+ ellipse 90% 80% at 50% 50%,
2004
+ ${hexToRGBA(purpleMid, 0.4)} 0%,
2005
+ ${hexToRGBA(purpleDark, 0.2)} 40%,
2006
+ transparent 70%
2007
+ );
2008
+ animation:sc-modal-glow 3s ease-in-out infinite;
2009
+ }
2010
+
2011
+ @keyframes sc-modal-glow {
2012
+ 0%, 100% { opacity:1; }
2013
+ 50% { opacity:0.85; }
2014
+ }
2015
+
2016
+ .sc-modal-card {
2017
+ position:relative;
2018
+ z-index:3;
2019
+ width:min(340px, 100%);
2020
+ background:${white};
2021
+ border-radius:${cardBorderRadius}px;
2022
+ padding:32px 24px 28px;
2023
+ box-shadow:0 24px 64px rgba(0,0,0,0.2);
2024
+ transform:scale(0.9) translateY(20px);
2025
+ ${modalAnimation}
2026
+ overflow:hidden;
2027
+ }
2028
+
2029
+ .sc-modal-overlay.active .sc-modal-card {
2030
+ transform:scale(1) translateY(0);
2031
+ }
2032
+
2033
+ .sc-modal-card::before {
2034
+ content:'';
2035
+ position:absolute;
2036
+ top:-50%;
2037
+ left:-50%;
2038
+ width:200%;
2039
+ height:200%;
2040
+ background:radial-gradient(circle at 50% 50%, ${hexToRGBA(purpleLight, 0.08)} 0%, transparent 50%);
2041
+ pointer-events:none;
2042
+ }
2043
+
2044
+ .sc-modal-header {
2045
+ text-align:center;
2046
+ margin-bottom:20px;
2047
+ }
2048
+
2049
+ .sc-modal-header h2 {
2050
+ font-size:1.5rem;
2051
+ font-weight:700;
2052
+ color:${modalTitleColor};
2053
+ line-height:1.3;
2054
+ }
2055
+
2056
+ .sc-modal-prize-box {
2057
+ background:linear-gradient(180deg, #f8f4fc 0%, #f0eaf8 100%);
2058
+ border-radius:20px;
2059
+ padding:28px 20px;
2060
+ margin-bottom:24px;
2061
+ text-align:center;
2062
+ position:relative;
2063
+ }
2064
+
2065
+ .sc-modal-prize-circle {
2066
+ width:100px;
2067
+ height:100px;
2068
+ margin:0 auto 16px;
2069
+ background:linear-gradient(145deg, ${purpleLight} 0%, ${purpleMid} 100%);
2070
+ border-radius:50%;
2071
+ display:flex;
2072
+ align-items:center;
2073
+ justify-content:center;
2074
+ font-size:2.8rem;
2075
+ box-shadow:0 8px 24px ${hexToRGBA(purpleDark, 0.3)};
2076
+ }
2077
+
2078
+ .sc-modal-prize-box .sc-prize-label {
2079
+ font-size:1.1rem;
2080
+ font-weight:600;
2081
+ color:${textDark};
2082
+ }
2083
+
2084
+ .sc-modal-prize-box .sc-prize-name {
2085
+ font-size:1.35rem;
2086
+ color:${purpleMid};
2087
+ margin-top:4px;
2088
+ }
2089
+
2090
+ .sc-btn-claim {
2091
+ display:block;
2092
+ width:100%;
2093
+ padding:16px 24px;
2094
+ border:none;
2095
+ border-radius:14px;
2096
+ background:${modalBtnBg};
2097
+ color:${modalBtnText};
2098
+ font-family:inherit;
2099
+ font-size:1.1rem;
2100
+ font-weight:600;
2101
+ cursor:pointer;
2102
+ box-shadow:0 6px 20px ${hexToRGBA(purpleDark, 0.4)};
2103
+ transition:transform 0.2s ease, box-shadow 0.2s ease;
2104
+ }
2105
+
2106
+ .sc-btn-claim:hover {
2107
+ transform:translateY(-2px);
2108
+ box-shadow:0 8px 28px ${hexToRGBA(purpleDark, 0.5)};
2109
+ }
2110
+
2111
+ .sc-btn-claim:active {
2112
+ transform:translateY(0);
2113
+ }
2114
+
2115
+ .sc-modal-terms {
2116
+ text-align:center;
2117
+ margin-top:16px;
2118
+ }
2119
+
2120
+ .sc-modal-terms a {
2121
+ font-size:0.875rem;
2122
+ color:${textMuted};
2123
+ text-decoration:none;
2124
+ }
2125
+
2126
+ .sc-modal-terms a:hover {
2127
+ color:${purpleMid};
2128
+ text-decoration:underline;
2129
+ }
2130
+
2131
+ /* ── prefers-reduced-motion ── */
2132
+ @media (prefers-reduced-motion: reduce) {
2133
+ .sc-wrapper { animation:none !important; opacity:1; transform:none; }
2134
+ .sc-page-bg { animation:none !important; }
2135
+ .sc-modal-bg-glow { animation:none !important; }
2136
+ .sc-modal-card { transition:none !important; transform:scale(1) translateY(0) !important; }
2137
+ .sc-btn-claim { transition:none !important; }
2138
+ .sc-btn-claim:hover { transform:none !important; }
2139
+ .sc-coin { transition:none !important; }
2140
+ }
2141
+ `;
2142
+ }
2143
+
2144
+ /**
2145
+ * ScratchCard SDK v1.1.0
2146
+ *
2147
+ * A fully dynamic, configurable Scratch Card component.
2148
+ * ES6 class-based. Outputs: UMD · ESM · CJS via Rollup.
2149
+ *
2150
+ * @example
2151
+ * import { ScratchCard } from 'spin-wheel-sdk';
2152
+ *
2153
+ * const card = ScratchCard.init({
2154
+ * container: '#app',
2155
+ * prize: { name: 'iPhone 16', icon: '📱', label: 'Congratulations!' },
2156
+ * onReveal(prize) { console.log(prize); },
2157
+ * });
2158
+ */
2159
+
2160
+
2161
+ /* ------------------------------------------------------------------ */
2162
+ /* Default configuration */
2163
+ /* ------------------------------------------------------------------ */
2164
+
2165
+ /** @type {ScratchCardOptions} */
2166
+ const DEFAULTS = Object.freeze({
2167
+ // ── Core ──
2168
+ container: null,
2169
+ prize: null,
2170
+
2171
+ // ── Header UI ──
2172
+ headerTitle: 'Scratch the Card to Win Exciting Prizes!',
2173
+ headerTitleColor: null, // uses theme.textDark if null
2174
+
2175
+ // ── Instruction ──
2176
+ instruction: 'Scratch the card to win exciting prizes.',
2177
+ instructionColor: null, // uses theme.textMuted if null
2178
+
2179
+ // ── Scratch Behavior ──
2180
+ revealThreshold: 55,
2181
+ brushSize: 28,
2182
+
2183
+ // ── Coin (Eraser Tool) ──
2184
+ coinSize: 56,
2185
+ showCoin: true,
2186
+ coinIcon: '$',
2187
+ coinGradientStart: null, // uses theme.goldLight if null
2188
+ coinGradientEnd: null, // uses theme.goldDark if null
2189
+
2190
+ // ── Card Appearance ──
2191
+ cardBackground: null, // uses default gradient if null
2192
+ cardShadow: true,
2193
+ cardBorderRadius: 24,
2194
+
2195
+ // ── Scratch Zone ──
2196
+ scratchZoneBackground: null, // uses theme gradient if null
2197
+ scratchZoneShadow: true,
2198
+ scratchZoneBorderRadius: 20,
2199
+
2200
+ // ── Scratch Layer ──
2201
+ scratchLayerColor: 'rgb(150, 130, 180)',
2202
+ scratchLayerSparkles: true,
2203
+ scratchLayerSparkleCount: 40,
2204
+
2205
+ // ── Prize Display ──
2206
+ prizeTextColor: null, // uses white with opacity if null
2207
+ prizeNameColor: null, // uses theme.goldLight if null
2208
+ prizeIconBackground: null, // uses white with opacity if null
2209
+
2210
+ // ── Gift Icon (Hint) ──
2211
+ showGiftIcon: true,
2212
+ giftIcon: '🎁',
2213
+ giftIconBackground: null, // uses white with opacity if null
2214
+
2215
+ // ── Modal ──
2216
+ showModal: true,
2217
+ modalTitle: 'Congratulations!',
2218
+ modalTitleColor: null, // uses theme.textDark if null
2219
+ modalButtonText: 'Claim your',
2220
+ modalButtonColor: null, // uses theme gradient if null
2221
+ modalButtonTextColor: null, // uses theme.white if null
2222
+ modalBackdropBlur: true,
2223
+
2224
+ // ── Confetti ──
2225
+ confettiEnabled: true,
2226
+ confettiColors: null, // uses default colors if null
2227
+ confettiCount: 100,
2228
+ confettiDuration: 5500,
2229
+
2230
+ // ── Animation ──
2231
+ animationType: 'default', // 'default' | 'bounce' | 'none'
2232
+ animationDuration: 600,
2233
+
2234
+ // ── Theme Colors ──
2235
+ theme: Object.freeze({
2236
+ purpleDark: '#4a2c6a',
2237
+ purpleMid: '#6b4a8a',
2238
+ purpleLight: '#8b6baa',
2239
+ gold: '#d4a84b',
2240
+ goldLight: '#e8c547',
2241
+ goldDark: '#b8923a',
2242
+ white: '#ffffff',
2243
+ textDark: '#2d2d2d',
2244
+ textMuted: '#6b6b6b',
2245
+ }),
2246
+
2247
+ // ── Callbacks ──
2248
+ onScratchStart: null,
2249
+ onScratchProgress: null,
2250
+ onReveal: null,
2251
+ onClaim: null,
2252
+ });
2253
+
2254
+ /* ------------------------------------------------------------------ */
2255
+ /* Validation */
2256
+ /* ------------------------------------------------------------------ */
2257
+
2258
+ function _validateOptions(opts) {
2259
+ // Container — truly required, cannot default
2260
+ if (!opts.container) {
2261
+ throw new Error('[ScratchCard] "container" is required.');
2262
+ }
2263
+
2264
+ // Prize — truly required, cannot default
2265
+ if (!opts.prize || typeof opts.prize !== 'object') {
2266
+ throw new Error('[ScratchCard] "prize" is required and must be an object.');
2267
+ }
2268
+ if (!opts.prize.name || typeof opts.prize.name !== 'string') {
2269
+ throw new Error('[ScratchCard] "prize.name" is required and must be a string.');
2270
+ }
2271
+
2272
+ // ── Clamp / auto-fix numeric & enum values instead of crashing ──
2273
+
2274
+ // revealThreshold: clamp to [10, 90]
2275
+ if (opts.revealThreshold !== undefined) {
2276
+ if (typeof opts.revealThreshold !== 'number' || isNaN(opts.revealThreshold)) {
2277
+ console.warn('[ScratchCard] "revealThreshold" must be a number. Using default (55).');
2278
+ opts.revealThreshold = DEFAULTS.revealThreshold;
2279
+ } else if (opts.revealThreshold < 10) {
2280
+ console.warn(`[ScratchCard] "revealThreshold" (${opts.revealThreshold}) is below 10. Clamping to 10.`);
2281
+ opts.revealThreshold = 10;
2282
+ } else if (opts.revealThreshold > 90) {
2283
+ console.warn(`[ScratchCard] "revealThreshold" (${opts.revealThreshold}) is above 90. Clamping to 90.`);
2284
+ opts.revealThreshold = 90;
2285
+ }
2286
+ }
2287
+
2288
+ // brushSize: clamp to min 1
2289
+ if (opts.brushSize !== undefined) {
2290
+ if (typeof opts.brushSize !== 'number' || isNaN(opts.brushSize)) {
2291
+ console.warn('[ScratchCard] "brushSize" must be a number. Using default (28).');
2292
+ opts.brushSize = DEFAULTS.brushSize;
2293
+ } else if (opts.brushSize < 1) {
2294
+ console.warn(`[ScratchCard] "brushSize" (${opts.brushSize}) is below 1. Clamping to 1.`);
2295
+ opts.brushSize = 1;
2296
+ }
2297
+ }
2298
+
2299
+ // coinSize: clamp to min 1
2300
+ if (opts.coinSize !== undefined) {
2301
+ if (typeof opts.coinSize !== 'number' || isNaN(opts.coinSize)) {
2302
+ console.warn('[ScratchCard] "coinSize" must be a number. Using default (56).');
2303
+ opts.coinSize = DEFAULTS.coinSize;
2304
+ } else if (opts.coinSize < 1) {
2305
+ console.warn(`[ScratchCard] "coinSize" (${opts.coinSize}) is below 1. Clamping to 1.`);
2306
+ opts.coinSize = 1;
2307
+ }
2308
+ }
2309
+
2310
+ // confettiCount: clamp to min 0
2311
+ if (opts.confettiCount !== undefined) {
2312
+ if (typeof opts.confettiCount !== 'number' || isNaN(opts.confettiCount)) {
2313
+ console.warn('[ScratchCard] "confettiCount" must be a number. Using default (100).');
2314
+ opts.confettiCount = DEFAULTS.confettiCount;
2315
+ } else if (opts.confettiCount < 0) {
2316
+ console.warn(`[ScratchCard] "confettiCount" (${opts.confettiCount}) is negative. Clamping to 0.`);
2317
+ opts.confettiCount = 0;
2318
+ }
2319
+ }
2320
+
2321
+ // confettiDuration: clamp to min 100
2322
+ if (opts.confettiDuration !== undefined) {
2323
+ if (typeof opts.confettiDuration !== 'number' || isNaN(opts.confettiDuration)) {
2324
+ console.warn('[ScratchCard] "confettiDuration" must be a number. Using default (5500).');
2325
+ opts.confettiDuration = DEFAULTS.confettiDuration;
2326
+ } else if (opts.confettiDuration <= 0) {
2327
+ console.warn(`[ScratchCard] "confettiDuration" (${opts.confettiDuration}) must be positive. Clamping to 100.`);
2328
+ opts.confettiDuration = 100;
2329
+ }
2330
+ }
2331
+
2332
+ // animationDuration: clamp to min 100
2333
+ if (opts.animationDuration !== undefined) {
2334
+ if (typeof opts.animationDuration !== 'number' || isNaN(opts.animationDuration)) {
2335
+ console.warn('[ScratchCard] "animationDuration" must be a number. Using default (600).');
2336
+ opts.animationDuration = DEFAULTS.animationDuration;
2337
+ } else if (opts.animationDuration <= 0) {
2338
+ console.warn(`[ScratchCard] "animationDuration" (${opts.animationDuration}) must be positive. Clamping to 100.`);
2339
+ opts.animationDuration = 100;
2340
+ }
2341
+ }
2342
+
2343
+ // animationType: fallback to 'default'
2344
+ if (opts.animationType !== undefined && !['default', 'bounce', 'none'].includes(opts.animationType)) {
2345
+ console.warn(`[ScratchCard] "animationType" ("${opts.animationType}") is invalid. Must be "default", "bounce", or "none". Using "default".`);
2346
+ opts.animationType = 'default';
2347
+ }
2348
+
2349
+ // cardBorderRadius: clamp to min 0
2350
+ if (opts.cardBorderRadius !== undefined) {
2351
+ if (typeof opts.cardBorderRadius !== 'number' || isNaN(opts.cardBorderRadius)) {
2352
+ console.warn('[ScratchCard] "cardBorderRadius" must be a number. Using default (24).');
2353
+ opts.cardBorderRadius = DEFAULTS.cardBorderRadius;
2354
+ } else if (opts.cardBorderRadius < 0) {
2355
+ console.warn(`[ScratchCard] "cardBorderRadius" (${opts.cardBorderRadius}) is negative. Clamping to 0.`);
2356
+ opts.cardBorderRadius = 0;
2357
+ }
2358
+ }
2359
+
2360
+ // scratchZoneBorderRadius: clamp to min 0
2361
+ if (opts.scratchZoneBorderRadius !== undefined) {
2362
+ if (typeof opts.scratchZoneBorderRadius !== 'number' || isNaN(opts.scratchZoneBorderRadius)) {
2363
+ console.warn('[ScratchCard] "scratchZoneBorderRadius" must be a number. Using default (20).');
2364
+ opts.scratchZoneBorderRadius = DEFAULTS.scratchZoneBorderRadius;
2365
+ } else if (opts.scratchZoneBorderRadius < 0) {
2366
+ console.warn(`[ScratchCard] "scratchZoneBorderRadius" (${opts.scratchZoneBorderRadius}) is negative. Clamping to 0.`);
2367
+ opts.scratchZoneBorderRadius = 0;
2368
+ }
2369
+ }
2370
+
2371
+ // scratchLayerSparkleCount: clamp to min 0
2372
+ if (opts.scratchLayerSparkleCount !== undefined) {
2373
+ if (typeof opts.scratchLayerSparkleCount !== 'number' || isNaN(opts.scratchLayerSparkleCount)) {
2374
+ console.warn('[ScratchCard] "scratchLayerSparkleCount" must be a number. Using default (40).');
2375
+ opts.scratchLayerSparkleCount = DEFAULTS.scratchLayerSparkleCount;
2376
+ } else if (opts.scratchLayerSparkleCount < 0) {
2377
+ console.warn(`[ScratchCard] "scratchLayerSparkleCount" (${opts.scratchLayerSparkleCount}) is negative. Clamping to 0.`);
2378
+ opts.scratchLayerSparkleCount = 0;
2379
+ }
2380
+ }
2381
+ }
2382
+
2383
+ /* ------------------------------------------------------------------ */
2384
+ /* SSR guard */
2385
+ /* ------------------------------------------------------------------ */
2386
+
2387
+ const _isBrowser = typeof window !== 'undefined' && typeof document !== 'undefined';
2388
+
2389
+ /** @returns {boolean} Whether the user prefers reduced motion */
2390
+ function _prefersReducedMotion() {
2391
+ return _isBrowser && window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches;
2392
+ }
2393
+
2394
+ /* ------------------------------------------------------------------ */
2395
+ /* CSS injection (once per page) */
2396
+ /* ------------------------------------------------------------------ */
2397
+
2398
+ let _injectedThemeKey = null;
2399
+
2400
+ function injectCSS(options) {
2401
+ if (!_isBrowser) return;
2402
+ const theme = { ...DEFAULTS.theme, ...(options.theme || {}) };
2403
+
2404
+ // Build a key from all style-affecting options
2405
+ const styleConfig = {
2406
+ theme,
2407
+ headerTitleColor: options.headerTitleColor,
2408
+ instructionColor: options.instructionColor,
2409
+ coinGradientStart: options.coinGradientStart,
2410
+ coinGradientEnd: options.coinGradientEnd,
2411
+ cardBackground: options.cardBackground,
2412
+ cardShadow: options.cardShadow,
2413
+ cardBorderRadius: options.cardBorderRadius,
2414
+ scratchZoneBackground: options.scratchZoneBackground,
2415
+ scratchZoneShadow: options.scratchZoneShadow,
2416
+ scratchZoneBorderRadius: options.scratchZoneBorderRadius,
2417
+ prizeTextColor: options.prizeTextColor,
2418
+ prizeNameColor: options.prizeNameColor,
2419
+ prizeIconBackground: options.prizeIconBackground,
2420
+ giftIconBackground: options.giftIconBackground,
2421
+ modalTitleColor: options.modalTitleColor,
2422
+ modalButtonColor: options.modalButtonColor,
2423
+ modalButtonTextColor: options.modalButtonTextColor,
2424
+ modalBackdropBlur: options.modalBackdropBlur,
2425
+ animationType: options.animationType,
2426
+ animationDuration: options.animationDuration,
2427
+ };
2428
+
2429
+ const key = JSON.stringify(styleConfig);
2430
+ if (_injectedThemeKey === key) return;
2431
+
2432
+ // Remove old stylesheet if exists (SSR-safe)
2433
+ const existing = _isBrowser ? document.querySelector('style[data-scratch-card-sdk]') : null;
2434
+ if (existing) existing.remove();
2435
+
2436
+ _injectedThemeKey = key;
2437
+ const style = document.createElement('style');
2438
+ style.setAttribute('data-scratch-card-sdk', '');
2439
+ style.textContent = buildScratchCSS(styleConfig, hexToRGBA);
2440
+ document.head.appendChild(style);
2441
+ }
2442
+
2443
+ /* ------------------------------------------------------------------ */
2444
+ /* ScratchCard class */
2445
+ /* ------------------------------------------------------------------ */
2446
+
2447
+ class ScratchCardInstance {
2448
+ /**
2449
+ * @param {ScratchCardOptions} opts
2450
+ */
2451
+ constructor(opts) {
2452
+ if (!_isBrowser) {
2453
+ throw new Error('[ScratchCard] ScratchCard requires a browser environment (window + document).');
2454
+ }
2455
+
2456
+ // ── validate options ──
2457
+ _validateOptions(opts);
2458
+
2459
+ /** @type {ScratchCardOptions} */
2460
+ this.options = deepMerge({}, DEFAULTS, opts);
2461
+
2462
+ // Ensure theme is properly merged
2463
+ this.options.theme = { ...DEFAULTS.theme, ...(opts.theme || {}) };
2464
+
2465
+ /** Current scratch percentage (0-100) */
2466
+ this.scratchPercent = 0;
2467
+
2468
+ /** Whether user is currently scratching */
2469
+ this.isScratching = false;
2470
+
2471
+ /** Whether user is dragging the coin */
2472
+ this.isDraggingCoin = false;
2473
+
2474
+ /** Whether prize has been revealed */
2475
+ this.hasRevealed = false;
2476
+
2477
+ /** Set to true after .destroy() */
2478
+ this._destroyed = false;
2479
+
2480
+ /** Whether onScratchStart has fired for this session */
2481
+ this._scratchStartFired = false;
2482
+
2483
+ /** Active confetti requestAnimationFrame ID */
2484
+ this._confettiRafId = null;
2485
+
2486
+ /** Confetti timeout IDs for burst effects */
2487
+ this._confettiTimeouts = [];
2488
+
2489
+ /** @type {object|null} */
2490
+ this._prize = this.options.prize;
2491
+
2492
+ // ── resolve container ──
2493
+ this.containerEl =
2494
+ typeof this.options.container === 'string'
2495
+ ? document.querySelector(this.options.container)
2496
+ : this.options.container;
2497
+
2498
+ if (!this.containerEl) {
2499
+ throw new Error('[ScratchCard] container not found. Ensure the container element exists in the DOM.');
2500
+ }
2501
+
2502
+ /** Canvas dimensions */
2503
+ this.zoneW = 0;
2504
+ this.zoneH = 0;
2505
+
2506
+ /** Last mouse/touch point for continuous scratching */
2507
+ this._lastPoint = null;
2508
+
2509
+ /** Resize observer for responsive canvas */
2510
+ this._resizeObserver = null;
2511
+
2512
+ // ── inject CSS (idempotent) ──
2513
+ injectCSS(this.options);
2514
+
2515
+ // ── DOM refs (set in _build) ──
2516
+ /** @type {HTMLElement} */ this._rootEl = null;
2517
+ /** @type {HTMLElement} */ this._zoneEl = null;
2518
+ /** @type {HTMLCanvasElement} */ this._canvasEl = null;
2519
+ /** @type {CanvasRenderingContext2D} */ this._ctx = null;
2520
+ /** @type {HTMLElement} */ this._coinEl = null;
2521
+ /** @type {HTMLElement} */ this._giftIconEl = null;
2522
+ /** @type {HTMLElement} */ this._modalEl = null;
2523
+ /** @type {HTMLElement} */ this._confettiCanvasEl = null;
2524
+ /** @type {HTMLElement} */ this._prizeContentEl = null;
2525
+ /** @type {HTMLElement} */ this._instructionEl = null;
2526
+
2527
+ // ── build & bind ──
2528
+ this._build();
2529
+ this._bind();
2530
+ }
2531
+
2532
+ /* ================================================================ */
2533
+ /* DOM construction */
2534
+ /* ================================================================ */
2535
+
2536
+ /** Build the full widget DOM tree and attach it to the container. */
2537
+ _build() {
2538
+ const o = this.options;
2539
+
2540
+ // ── root wrapper ──
2541
+ const root = document.createElement('div');
2542
+ root.className = 'sc-root';
2543
+
2544
+ // ── page background glow ──
2545
+ const pageBg = document.createElement('div');
2546
+ pageBg.className = 'sc-page-bg';
2547
+ pageBg.setAttribute('aria-hidden', 'true');
2548
+ root.appendChild(pageBg);
2549
+
2550
+ // ── main wrapper ──
2551
+ const wrapper = document.createElement('div');
2552
+ wrapper.className = 'sc-wrapper';
2553
+
2554
+ // ── main card ──
2555
+ const card = document.createElement('div');
2556
+ card.className = 'sc-main-card';
2557
+
2558
+ // ── header ──
2559
+ const header = document.createElement('header');
2560
+ header.className = 'sc-header';
2561
+ header.innerHTML = `<h1>${escapeHtml(o.headerTitle)}</h1>`;
2562
+ card.appendChild(header);
2563
+
2564
+ // ── scratch zone ──
2565
+ const zone = document.createElement('div');
2566
+ zone.className = 'sc-zone';
2567
+ zone.id = `sc-zone-${this._id()}`;
2568
+ this._zoneEl = zone;
2569
+
2570
+ // Prize content (under canvas)
2571
+ const prizeContent = document.createElement('div');
2572
+ prizeContent.className = 'sc-prize-content';
2573
+ prizeContent.innerHTML = `
2574
+ <div class="sc-prize-text">${escapeHtml(this._prize.label || 'Congratulations!')}</div>
2575
+ <div class="sc-prize-name">${escapeHtml(this._prize.name)}</div>
2576
+ <div class="sc-prize-icon">${escapeHtml(this._prize.icon || '🎁')}</div>
2577
+ `;
2578
+ zone.appendChild(prizeContent);
2579
+ this._prizeContentEl = prizeContent;
2580
+
2581
+ // Canvas for scratching
2582
+ const canvas = document.createElement('canvas');
2583
+ canvas.className = 'sc-canvas';
2584
+ canvas.id = `sc-canvas-${this._id()}`;
2585
+ canvas.setAttribute('aria-label', 'Scratch to reveal your prize');
2586
+ zone.appendChild(canvas);
2587
+ this._canvasEl = canvas;
2588
+ this._ctx = canvas.getContext('2d');
2589
+
2590
+ // Gift icon visible above scratch layer (hint icon)
2591
+ if (o.showGiftIcon !== false) {
2592
+ const prizeIconVisible = document.createElement('div');
2593
+ prizeIconVisible.className = 'sc-prize-icon-visible';
2594
+ prizeIconVisible.id = `sc-gift-icon-${this._id()}`;
2595
+ prizeIconVisible.setAttribute('aria-hidden', 'true');
2596
+ prizeIconVisible.textContent = o.giftIcon || '🎁';
2597
+ zone.appendChild(prizeIconVisible);
2598
+ this._giftIconEl = prizeIconVisible;
2599
+ }
2600
+
2601
+ // Draggable coin eraser
2602
+ if (o.showCoin) {
2603
+ const coin = document.createElement('div');
2604
+ coin.className = 'sc-coin';
2605
+ coin.id = `sc-coin-${this._id()}`;
2606
+ coin.setAttribute('aria-hidden', 'true');
2607
+ coin.textContent = o.coinIcon;
2608
+ zone.appendChild(coin);
2609
+ this._coinEl = coin;
2610
+ }
2611
+
2612
+ card.appendChild(zone);
2613
+
2614
+ // ── instruction ──
2615
+ const instruction = document.createElement('p');
2616
+ instruction.className = 'sc-instruction';
2617
+ instruction.textContent = o.instruction;
2618
+ card.appendChild(instruction);
2619
+ this._instructionEl = instruction;
2620
+
2621
+ wrapper.appendChild(card);
2622
+ root.appendChild(wrapper);
2623
+ this.containerEl.appendChild(root);
2624
+ this._rootEl = root;
2625
+
2626
+ // ── modal ──
2627
+ if (o.showModal) {
2628
+ this._buildModal();
2629
+ }
2630
+
2631
+ // ── initialize canvas size ──
2632
+ this._initCanvas();
2633
+ }
2634
+
2635
+ /** Generate a unique ID for this instance */
2636
+ _id() {
2637
+ return Math.random().toString(36).slice(2, 9);
2638
+ }
2639
+
2640
+ /** Build the win modal and append it to the container */
2641
+ _buildModal() {
2642
+ const o = this.options;
2643
+ const modalTitle = o.modalTitle || 'Congratulations!';
2644
+ const modalButtonText = o.modalButtonText || 'Claim your';
2645
+
2646
+ const overlay = document.createElement('div');
2647
+ overlay.className = 'sc-modal-overlay';
2648
+ overlay.setAttribute('role', 'dialog');
2649
+ overlay.setAttribute('aria-modal', 'true');
2650
+ overlay.setAttribute('aria-hidden', 'true');
2651
+ overlay.setAttribute('aria-label', 'Prize won');
2652
+
2653
+ // Confetti canvas
2654
+ const confettiCanvas = document.createElement('canvas');
2655
+ confettiCanvas.className = 'sc-confetti-canvas';
2656
+ confettiCanvas.setAttribute('aria-hidden', 'true');
2657
+ overlay.appendChild(confettiCanvas);
2658
+ this._confettiCanvasEl = confettiCanvas;
2659
+
2660
+ // Background glow
2661
+ const bgGlow = document.createElement('div');
2662
+ bgGlow.className = 'sc-modal-bg-glow';
2663
+ overlay.appendChild(bgGlow);
2664
+
2665
+ // Modal card
2666
+ const modalCard = document.createElement('div');
2667
+ modalCard.className = 'sc-modal-card';
2668
+ modalCard.innerHTML = `
2669
+ <div class="sc-modal-header">
2670
+ <h2>${escapeHtml(modalTitle)} You won ${escapeHtml(this._prize.name)}.</h2>
2671
+ </div>
2672
+ <div class="sc-modal-prize-box">
2673
+ <div class="sc-modal-prize-circle">${escapeHtml(this._prize.icon || '🎁')}</div>
2674
+ <div class="sc-prize-label">${escapeHtml(this._prize.label || modalTitle)} You won ${escapeHtml(this._prize.name)}!</div>
2675
+ <div class="sc-prize-name">${escapeHtml(this._prize.name)}</div>
2676
+ </div>
2677
+ <button type="button" class="sc-btn-claim">${escapeHtml(modalButtonText)} ${escapeHtml(this._prize.name)}</button>
2678
+ <p class="sc-modal-terms">
2679
+ <a href="#" onclick="return false;">Terms & Conditions</a>
2680
+ </p>
2681
+ `;
2682
+ overlay.appendChild(modalCard);
2683
+
2684
+ this.containerEl.appendChild(overlay);
2685
+ this._modalEl = overlay;
2686
+ }
2687
+
2688
+ /** Initialize canvas size and draw scratch surface */
2689
+ _initCanvas() {
2690
+ const setSize = () => {
2691
+ const w = this._zoneEl.clientWidth;
2692
+ const h = this._zoneEl.clientHeight;
2693
+ if (w <= 0 || h <= 0) return;
2694
+
2695
+ this.zoneW = w;
2696
+ this.zoneH = h;
2697
+
2698
+ const dpr = window.devicePixelRatio || 1;
2699
+ this._canvasEl.width = Math.ceil(w * dpr);
2700
+ this._canvasEl.height = Math.ceil(h * dpr);
2701
+ this._canvasEl.style.width = w + 'px';
2702
+ this._canvasEl.style.height = h + 'px';
2703
+
2704
+ this._ctx.setTransform(1, 0, 0, 1, 0, 0);
2705
+ this._ctx.scale(dpr, dpr);
2706
+
2707
+ this._drawSurface();
2708
+ this._positionCoinInitial();
2709
+ this._zoneEl.classList.add('sc-ready');
2710
+ };
2711
+
2712
+ // Use ResizeObserver for responsive sizing
2713
+ this._resizeObserver = new ResizeObserver(() => {
2714
+ requestAnimationFrame(setSize);
2715
+ });
2716
+ this._resizeObserver.observe(this._zoneEl);
2717
+
2718
+ // Initial sizing
2719
+ requestAnimationFrame(() => requestAnimationFrame(setSize));
2720
+ }
2721
+
2722
+ /** Draw the scratchable surface on canvas */
2723
+ _drawSurface() {
2724
+ const w = this.zoneW;
2725
+ const h = this.zoneH;
2726
+ if (w <= 0 || h <= 0) return;
2727
+
2728
+ const ctx = this._ctx;
2729
+ const o = this.options;
2730
+
2731
+ // Base color for scratch layer (configurable)
2732
+ ctx.fillStyle = o.scratchLayerColor || 'rgb(150, 130, 180)';
2733
+ ctx.fillRect(0, 0, w, h);
2734
+
2735
+ // Add sparkle effect if enabled
2736
+ if (o.scratchLayerSparkles !== false) {
2737
+ const sparkleCount = o.scratchLayerSparkleCount || 40;
2738
+ ctx.globalCompositeOperation = 'overlay';
2739
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
2740
+ for (let i = 0; i < sparkleCount; i++) {
2741
+ ctx.beginPath();
2742
+ ctx.arc(Math.random() * w, Math.random() * h, 2 + Math.random() * 3, 0, Math.PI * 2);
2743
+ ctx.fill();
2744
+ }
2745
+ ctx.globalCompositeOperation = 'source-over';
2746
+ }
2747
+ }
2748
+
2749
+ /** Position coin at initial location */
2750
+ _positionCoinInitial() {
2751
+ if (!this._coinEl || this.zoneW <= 0 || this.zoneH <= 0) return;
2752
+
2753
+ const coinRadius = this.options.coinSize / 2;
2754
+ const x = Math.round(this.zoneW * 0.15 - coinRadius);
2755
+ const y = Math.round(this.zoneH * 0.82 - coinRadius);
2756
+ this._coinEl.style.transform = `translate(${x}px, ${y}px)`;
2757
+ }
2758
+
2759
+ /* ================================================================ */
2760
+ /* Event binding */
2761
+ /* ================================================================ */
2762
+
2763
+ /** Wire up all interactive event listeners with proper references for cleanup. */
2764
+ _bind() {
2765
+ const canvas = this._canvasEl;
2766
+
2767
+ // Canvas scratch events (direct mouse/touch scratching)
2768
+ this._boundStartScratch = this._onStartScratch.bind(this);
2769
+ this._boundMoveScratch = this._onMoveScratch.bind(this);
2770
+ this._boundEndScratch = this._onEndScratch.bind(this);
2771
+
2772
+ canvas.addEventListener('mousedown', this._boundStartScratch);
2773
+ canvas.addEventListener('mousemove', this._boundMoveScratch);
2774
+ canvas.addEventListener('mouseup', this._boundEndScratch);
2775
+ canvas.addEventListener('mouseleave', this._boundEndScratch);
2776
+
2777
+ canvas.addEventListener('touchstart', this._boundStartScratch, { passive: false });
2778
+ canvas.addEventListener('touchmove', this._boundMoveScratch, { passive: false });
2779
+ canvas.addEventListener('touchend', this._boundEndScratch);
2780
+ canvas.addEventListener('touchcancel', this._boundEndScratch);
2781
+
2782
+ // Coin drag events
2783
+ if (this._coinEl) {
2784
+ this._boundStartCoinDrag = this._onStartCoinDrag.bind(this);
2785
+ this._coinEl.addEventListener('mousedown', this._boundStartCoinDrag);
2786
+ this._coinEl.addEventListener('touchstart', this._boundStartCoinDrag, { passive: false });
2787
+ }
2788
+
2789
+ // Global drag events for coin – store references for cleanup in destroy()
2790
+ this._boundDocMouseMove = (e) => {
2791
+ if (this.isDraggingCoin) this._onMoveCoinDrag(e);
2792
+ };
2793
+ this._boundDocMouseUp = this._onEndCoinDrag.bind(this);
2794
+ this._boundDocTouchMove = (e) => {
2795
+ if (this.isDraggingCoin) {
2796
+ e.preventDefault();
2797
+ this._onMoveCoinDrag(e);
2798
+ }
2799
+ };
2800
+ this._boundDocTouchEnd = this._onEndCoinDrag.bind(this);
2801
+ this._boundDocTouchCancel = this._onEndCoinDrag.bind(this);
2802
+
2803
+ document.addEventListener('mousemove', this._boundDocMouseMove);
2804
+ document.addEventListener('mouseup', this._boundDocMouseUp);
2805
+ document.addEventListener('touchmove', this._boundDocTouchMove, { passive: false });
2806
+ document.addEventListener('touchend', this._boundDocTouchEnd);
2807
+ document.addEventListener('touchcancel', this._boundDocTouchCancel);
2808
+
2809
+ // Modal events
2810
+ if (this._modalEl) {
2811
+ this._boundModalClick = (e) => {
2812
+ if (e.target === this._modalEl || e.target.classList.contains('sc-modal-bg-glow')) {
2813
+ this.hideModal();
2814
+ }
2815
+ };
2816
+ this._modalEl.addEventListener('click', this._boundModalClick);
2817
+
2818
+ const btnClaim = this._modalEl.querySelector('.sc-btn-claim');
2819
+ if (btnClaim) {
2820
+ this._boundClaimClick = () => {
2821
+ this.hideModal();
2822
+ if (typeof this.options.onClaim === 'function') {
2823
+ try { this.options.onClaim(this._prize); } catch (err) { console.error('[ScratchCard] onClaim callback error:', err); }
2824
+ }
2825
+ };
2826
+ this._claimBtnEl = btnClaim;
2827
+ btnClaim.addEventListener('click', this._boundClaimClick);
2828
+ }
2829
+ }
2830
+
2831
+ // Global keyboard: Escape closes modal
2832
+ this._boundDocKeydown = (e) => {
2833
+ if (e.key === 'Escape' && this._modalEl?.classList.contains('active')) {
2834
+ this.hideModal();
2835
+ }
2836
+ };
2837
+ document.addEventListener('keydown', this._boundDocKeydown);
2838
+ }
2839
+
2840
+ /** Get event point relative to scratch zone */
2841
+ _getEventPoint(e) {
2842
+ if (!this._zoneEl) return { x: 0, y: 0 };
2843
+ const rect = this._zoneEl.getBoundingClientRect();
2844
+ if (e.touches && e.touches.length) {
2845
+ return {
2846
+ x: e.touches[0].clientX - rect.left,
2847
+ y: e.touches[0].clientY - rect.top,
2848
+ };
2849
+ }
2850
+ return {
2851
+ x: e.clientX - rect.left,
2852
+ y: e.clientY - rect.top,
2853
+ };
2854
+ }
2855
+
2856
+ /** Start scratching */
2857
+ _onStartScratch(e) {
2858
+ if (this.hasRevealed || this._destroyed) return;
2859
+ e.preventDefault();
2860
+ this.isScratching = true;
2861
+
2862
+ // Hide gift icon as soon as user starts scratching
2863
+ if (this._giftIconEl) {
2864
+ this._giftIconEl.style.display = 'none';
2865
+ }
2866
+
2867
+ this._lastPoint = this._getEventPoint(e);
2868
+ this._scratch(this._lastPoint.x, this._lastPoint.y);
2869
+
2870
+ if (!this._scratchStartFired && typeof this.options.onScratchStart === 'function') {
2871
+ this._scratchStartFired = true;
2872
+ try { this.options.onScratchStart(); } catch (err) { console.error('[ScratchCard] onScratchStart callback error:', err); }
2873
+ }
2874
+ }
2875
+
2876
+ /** Move scratching */
2877
+ _onMoveScratch(e) {
2878
+ if (!this.isScratching || this.hasRevealed || this._destroyed) return;
2879
+ e.preventDefault();
2880
+ const point = this._getEventPoint(e);
2881
+ const dx = point.x - this._lastPoint.x;
2882
+ const dy = point.y - this._lastPoint.y;
2883
+ const dist = Math.sqrt(dx * dx + dy * dy);
2884
+ const steps = Math.max(1, Math.floor(dist / 4));
2885
+
2886
+ for (let i = 1; i <= steps; i++) {
2887
+ const t = i / steps;
2888
+ const x = this._lastPoint.x + dx * t;
2889
+ const y = this._lastPoint.y + dy * t;
2890
+ this._scratch(x, y);
2891
+ }
2892
+ this._lastPoint = point;
2893
+ }
2894
+
2895
+ /** End scratching */
2896
+ _onEndScratch() {
2897
+ this.isScratching = false;
2898
+ this._lastPoint = null;
2899
+ }
2900
+
2901
+ /** Perform scratch at position */
2902
+ _scratch(x, y, radius) {
2903
+ if (!this._ctx || this._destroyed) return;
2904
+ const r = radius !== undefined ? radius : this.options.brushSize / 2;
2905
+ const ctx = this._ctx;
2906
+
2907
+ ctx.globalCompositeOperation = 'destination-out';
2908
+ ctx.fillStyle = 'rgba(0, 0, 0, 1)';
2909
+ ctx.beginPath();
2910
+ ctx.arc(x, y, r, 0, Math.PI * 2);
2911
+ ctx.fill();
2912
+ ctx.globalCompositeOperation = 'source-over';
2913
+
2914
+ if (this.hasRevealed) return;
2915
+ this._updateScratchPercent();
2916
+ }
2917
+
2918
+ /** Calculate and update scratch percentage */
2919
+ _updateScratchPercent() {
2920
+ if (!this._ctx || this._destroyed) return;
2921
+ const w = this.zoneW;
2922
+ const h = this.zoneH;
2923
+ if (w <= 0 || h <= 0) return;
2924
+ const dpr = window.devicePixelRatio || 1;
2925
+ const cw = Math.ceil(w * dpr);
2926
+ const ch = Math.ceil(h * dpr);
2927
+ if (cw <= 0 || ch <= 0) return;
2928
+
2929
+ let imageData;
2930
+ try {
2931
+ imageData = this._ctx.getImageData(0, 0, cw, ch);
2932
+ } catch (_) { return; }
2933
+ const data = imageData.data;
2934
+ let transparent = 0;
2935
+ const step = 8; // Sample every 8th pixel for performance
2936
+
2937
+ for (let i = 0; i < data.length; i += 4 * step) {
2938
+ if (data[i + 3] < 128) transparent++;
2939
+ }
2940
+
2941
+ const total = (cw * ch) / step;
2942
+ this.scratchPercent = total > 0 ? Math.round((transparent / total) * 100) : 0;
2943
+
2944
+ if (typeof this.options.onScratchProgress === 'function') {
2945
+ try { this.options.onScratchProgress(this.scratchPercent); } catch (err) { console.error('[ScratchCard] onScratchProgress callback error:', err); }
2946
+ }
2947
+
2948
+ if (this.scratchPercent >= this.options.revealThreshold) {
2949
+ this._reveal();
2950
+ }
2951
+ }
2952
+
2953
+ /** Reveal the prize */
2954
+ _reveal() {
2955
+ if (this.hasRevealed || this._destroyed) return;
2956
+ this.hasRevealed = true;
2957
+
2958
+ // Clear the canvas to fully reveal
2959
+ if (this._ctx) {
2960
+ try { this._ctx.clearRect(0, 0, this.zoneW, this.zoneH); } catch (_) { /* noop */ }
2961
+ }
2962
+
2963
+ // Hide the gift icon that sits above the scratch layer
2964
+ if (this._giftIconEl) {
2965
+ this._giftIconEl.style.display = 'none';
2966
+ }
2967
+
2968
+ // Show modal
2969
+ if (this._modalEl) {
2970
+ this._modalEl.classList.add('active');
2971
+ this._modalEl.setAttribute('aria-hidden', 'false');
2972
+
2973
+ // Start confetti only if enabled and motion is OK
2974
+ if (this.options.confettiEnabled !== false && !_prefersReducedMotion()) {
2975
+ this._startConfetti();
2976
+ }
2977
+
2978
+ // Focus the claim button for keyboard users
2979
+ const claimBtn = this._modalEl.querySelector('.sc-btn-claim');
2980
+ if (claimBtn) {
2981
+ setTimeout(() => claimBtn.focus(), 100);
2982
+ }
2983
+ }
2984
+
2985
+ // A11y: announce to screen readers
2986
+ this._announceToScreenReader(`Prize revealed: ${this._prize?.name || 'your prize'}`);
2987
+
2988
+ if (typeof this.options.onReveal === 'function') {
2989
+ try { this.options.onReveal(this._prize); } catch (err) { console.error('[ScratchCard] onReveal callback error:', err); }
2990
+ }
2991
+ }
2992
+
2993
+ /* ================================================================ */
2994
+ /* Coin drag handling */
2995
+ /* ================================================================ */
2996
+
2997
+ _onStartCoinDrag(e) {
2998
+ if (this.hasRevealed || this._destroyed) return;
2999
+ e.preventDefault();
3000
+ e.stopPropagation();
3001
+ this.isDraggingCoin = true;
3002
+ if (this._coinEl) this._coinEl.classList.add('dragging');
3003
+
3004
+ // Hide gift icon as soon as user starts scratching
3005
+ if (this._giftIconEl) {
3006
+ this._giftIconEl.style.display = 'none';
3007
+ }
3008
+
3009
+ const point = this._getEventPoint(e);
3010
+ const c = this._clampCoinPosition(point.x, point.y);
3011
+ this._updateCoinPosition(c.x, c.y);
3012
+ this._scratch(c.x, c.y, this.options.coinSize / 2);
3013
+
3014
+ if (!this._scratchStartFired && typeof this.options.onScratchStart === 'function') {
3015
+ this._scratchStartFired = true;
3016
+ try { this.options.onScratchStart(); } catch (err) { console.error('[ScratchCard] onScratchStart callback error:', err); }
3017
+ }
3018
+ }
3019
+
3020
+ _onMoveCoinDrag(e) {
3021
+ if (!this.isDraggingCoin || this.hasRevealed || this._destroyed) return;
3022
+ e.preventDefault();
3023
+ e.stopPropagation();
3024
+
3025
+ const point = this._getEventPoint(e);
3026
+ const c = this._clampCoinPosition(point.x, point.y);
3027
+ this._updateCoinPosition(c.x, c.y);
3028
+ this._scratch(c.x, c.y, this.options.coinSize / 2);
3029
+ }
3030
+
3031
+ _onEndCoinDrag() {
3032
+ if (!this.isDraggingCoin) return;
3033
+ this.isDraggingCoin = false;
3034
+ if (this._coinEl) this._coinEl.classList.remove('dragging');
3035
+ }
3036
+
3037
+ _clampCoinPosition(cx, cy) {
3038
+ const r = this.options.coinSize / 2;
3039
+ return {
3040
+ x: Math.max(r, Math.min(this.zoneW - r, cx)),
3041
+ y: Math.max(r, Math.min(this.zoneH - r, cy)),
3042
+ };
3043
+ }
3044
+
3045
+ _updateCoinPosition(cx, cy) {
3046
+ if (!this._coinEl) return;
3047
+ const r = this.options.coinSize / 2;
3048
+ this._coinEl.style.transform = `translate(${cx - r}px, ${cy - r}px)`;
3049
+ }
3050
+
3051
+ /* ================================================================ */
3052
+ /* Confetti animation */
3053
+ /* ================================================================ */
3054
+
3055
+ _startConfetti() {
3056
+ if (!this._confettiCanvasEl || !this._modalEl || this._destroyed) return;
3057
+
3058
+ const o = this.options;
3059
+ const rect = this._modalEl.getBoundingClientRect();
3060
+ const w = rect.width;
3061
+ const h = rect.height;
3062
+ if (w <= 0 || h <= 0) return;
3063
+
3064
+ this._confettiCanvasEl.width = w;
3065
+ this._confettiCanvasEl.height = h;
3066
+ this._confettiCanvasEl.style.width = w + 'px';
3067
+ this._confettiCanvasEl.style.height = h + 'px';
3068
+
3069
+ const cctx = this._confettiCanvasEl.getContext('2d');
3070
+ if (!cctx) return;
3071
+ const colors = o.confettiColors || ['#e8c547', '#d4a84b', '#6b4a8a', '#8b6baa', '#c94a6a', '#4ac97a', '#fff', '#ff6b6b'];
3072
+ const particles = [];
3073
+ const centerX = w / 2;
3074
+ const centerY = h / 2;
3075
+ const count = o.confettiCount || 100;
3076
+
3077
+ for (let i = 0; i < count; i++) {
3078
+ const angle = (Math.PI * 2 * i) / count + Math.random() * 0.5;
3079
+ const speed = 4 + Math.random() * 10;
3080
+ particles.push({
3081
+ x: centerX,
3082
+ y: centerY,
3083
+ vx: Math.cos(angle) * speed + (Math.random() - 0.5) * 4,
3084
+ vy: Math.sin(angle) * speed - 6 + (Math.random() - 0.5) * 2,
3085
+ color: colors[Math.floor(Math.random() * colors.length)],
3086
+ size: 4 + Math.random() * 8,
3087
+ rotation: Math.random() * Math.PI * 2,
3088
+ rotationSpeed: (Math.random() - 0.5) * 0.3,
3089
+ opacity: 1,
3090
+ birth: 0,
3091
+ });
3092
+ }
3093
+
3094
+ // Add burst effects
3095
+ this._confettiTimeouts.push(
3096
+ setTimeout(() => this._addBurst(particles, centerX, centerY, 35, cctx, w, h), 200),
3097
+ setTimeout(() => this._addBurst(particles, centerX, centerY, 30, cctx, w, h), 450)
3098
+ );
3099
+
3100
+ const gravity = 0.22;
3101
+ const friction = 0.99;
3102
+ const startTime = Date.now();
3103
+ const duration = o.confettiDuration || 5500;
3104
+
3105
+ const animate = () => {
3106
+ const elapsed = Date.now() - startTime;
3107
+ if (elapsed > duration || this._destroyed) {
3108
+ cctx.clearRect(0, 0, w, h);
3109
+ this._confettiRafId = null;
3110
+ return;
3111
+ }
3112
+
3113
+ cctx.clearRect(0, 0, w, h);
3114
+
3115
+ for (let i = 0; i < particles.length; i++) {
3116
+ const p = particles[i];
3117
+ const age = p.birth ? Date.now() - p.birth : elapsed;
3118
+
3119
+ p.x += p.vx;
3120
+ p.y += p.vy;
3121
+ p.vy += gravity;
3122
+ p.vx *= friction;
3123
+ p.vy *= friction;
3124
+ p.rotation += p.rotationSpeed;
3125
+
3126
+ const life = 1 - age / (p.birth ? 3200 : duration);
3127
+ p.opacity = Math.max(0, life);
3128
+
3129
+ if (p.opacity <= 0 || p.y > h + 20) continue;
3130
+
3131
+ cctx.save();
3132
+ cctx.globalAlpha = p.opacity;
3133
+ cctx.translate(p.x, p.y);
3134
+ cctx.rotate(p.rotation);
3135
+ cctx.fillStyle = p.color;
3136
+ cctx.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2);
3137
+ cctx.restore();
3138
+ }
3139
+
3140
+ this._confettiRafId = requestAnimationFrame(animate);
3141
+ };
3142
+
3143
+ this._confettiRafId = requestAnimationFrame(animate);
3144
+ }
3145
+
3146
+ _addBurst(particles, x, y, count, cctx, w, h) {
3147
+ const o = this.options;
3148
+ const colors = o.confettiColors || ['#e8c547', '#d4a84b', '#6b4a8a', '#8b6baa', '#c94a6a', '#4ac97a', '#fff', '#ff6b6b'];
3149
+ for (let i = 0; i < count; i++) {
3150
+ const angle = Math.random() * Math.PI * 2;
3151
+ const speed = 3 + Math.random() * 8;
3152
+ particles.push({
3153
+ x: x + (Math.random() - 0.5) * 120,
3154
+ y: y - 40 + (Math.random() - 0.5) * 20,
3155
+ vx: Math.cos(angle) * speed,
3156
+ vy: Math.sin(angle) * speed - 4,
3157
+ color: colors[Math.floor(Math.random() * colors.length)],
3158
+ size: 3 + Math.random() * 6,
3159
+ rotation: Math.random() * Math.PI * 2,
3160
+ rotationSpeed: (Math.random() - 0.5) * 0.3,
3161
+ opacity: 1,
3162
+ birth: Date.now(),
3163
+ });
3164
+ }
3165
+ }
3166
+
3167
+ /* ================================================================ */
3168
+ /* Public API */
3169
+ /* ================================================================ */
3170
+
3171
+ /**
3172
+ * Programmatically reveal the prize.
3173
+ */
3174
+ reveal() {
3175
+ if (this._destroyed) return;
3176
+ this._reveal();
3177
+ }
3178
+
3179
+ /**
3180
+ * Hide the modal overlay.
3181
+ */
3182
+ hideModal() {
3183
+ if (this._destroyed || !this._modalEl) return;
3184
+ this._modalEl.classList.remove('active');
3185
+ this._modalEl.setAttribute('aria-hidden', 'true');
3186
+ }
3187
+
3188
+ /**
3189
+ * Show the modal overlay.
3190
+ */
3191
+ showModal() {
3192
+ if (this._destroyed || !this._modalEl) return;
3193
+ this._modalEl.classList.add('active');
3194
+ this._modalEl.setAttribute('aria-hidden', 'false');
3195
+ }
3196
+
3197
+ /**
3198
+ * Reset the scratch card for replay.
3199
+ */
3200
+ reset() {
3201
+ if (this._destroyed) return;
3202
+
3203
+ this.hasRevealed = false;
3204
+ this.scratchPercent = 0;
3205
+ this.isScratching = false;
3206
+ this.isDraggingCoin = false;
3207
+ this._lastPoint = null;
3208
+ this._scratchStartFired = false;
3209
+
3210
+ // Redraw canvas
3211
+ this._drawSurface();
3212
+
3213
+ // Reset coin position
3214
+ this._positionCoinInitial();
3215
+
3216
+ // Show the gift icon again
3217
+ if (this._giftIconEl) {
3218
+ this._giftIconEl.style.display = '';
3219
+ }
3220
+
3221
+ // Hide modal
3222
+ this.hideModal();
3223
+
3224
+ // Remove ready class temporarily
3225
+ if (this._zoneEl) {
3226
+ this._zoneEl.classList.remove('sc-ready');
3227
+ requestAnimationFrame(() => {
3228
+ if (this._zoneEl) this._zoneEl.classList.add('sc-ready');
3229
+ });
3230
+ }
3231
+ }
3232
+
3233
+ /**
3234
+ * Update the prize at runtime.
3235
+ * @param {object} prize - New prize object
3236
+ */
3237
+ updatePrize(prize) {
3238
+ if (this._destroyed) return;
3239
+ if (!prize || typeof prize !== 'object') {
3240
+ console.warn('[ScratchCard] updatePrize: prize must be an object. Ignoring.');
3241
+ return;
3242
+ }
3243
+ this._prize = { ...this._prize, ...prize };
3244
+ this.reset();
3245
+
3246
+ const o = this.options;
3247
+ const modalTitle = o.modalTitle || 'Congratulations!';
3248
+ const modalButtonText = o.modalButtonText || 'Claim your';
3249
+
3250
+ // Update prize display
3251
+ if (this._prizeContentEl) {
3252
+ this._prizeContentEl.innerHTML = `
3253
+ <div class="sc-prize-text">${escapeHtml(this._prize.label || modalTitle)}</div>
3254
+ <div class="sc-prize-name">${escapeHtml(this._prize.name)}</div>
3255
+ <div class="sc-prize-icon">${escapeHtml(this._prize.icon || '🎁')}</div>
3256
+ `;
3257
+ }
3258
+
3259
+ // Update modal content
3260
+ if (this._modalEl) {
3261
+ const header = this._modalEl.querySelector('.sc-modal-header h2');
3262
+ if (header) header.textContent = `${modalTitle} You won ${this._prize.name}.`;
3263
+
3264
+ const circle = this._modalEl.querySelector('.sc-modal-prize-circle');
3265
+ if (circle) circle.textContent = this._prize.icon || '🎁';
3266
+
3267
+ const label = this._modalEl.querySelector('.sc-modal-prize-box .sc-prize-label');
3268
+ if (label) label.textContent = `${this._prize.label || modalTitle} You won ${this._prize.name}!`;
3269
+
3270
+ const name = this._modalEl.querySelector('.sc-modal-prize-box .sc-prize-name');
3271
+ if (name) name.textContent = this._prize.name;
3272
+
3273
+ const btn = this._modalEl.querySelector('.sc-btn-claim');
3274
+ if (btn) btn.textContent = `${modalButtonText} ${this._prize.name}`;
3275
+ }
3276
+ }
3277
+
3278
+ /**
3279
+ * Update configuration options at runtime.
3280
+ * @param {Partial<ScratchCardOptions>} newOptions
3281
+ */
3282
+ updateOptions(newOptions) {
3283
+ if (this._destroyed) return;
3284
+ if (!newOptions || typeof newOptions !== 'object') {
3285
+ console.warn('[ScratchCard] updateOptions: argument must be an object. Ignoring.');
3286
+ return;
3287
+ }
3288
+
3289
+ // Sanitize numeric/enum values before merging
3290
+ _validateOptions({ ...this.options, ...newOptions, container: this.options.container, prize: this.options.prize });
3291
+
3292
+ // Merge new options
3293
+ this.options = deepMerge({}, this.options, newOptions);
3294
+
3295
+ // Handle theme separately for proper nested merge
3296
+ if (newOptions.theme) {
3297
+ this.options.theme = { ...DEFAULTS.theme, ...this.options.theme, ...newOptions.theme };
3298
+ }
3299
+
3300
+ // Re-inject CSS if styling options changed
3301
+ injectCSS(this.options);
3302
+
3303
+ // Update header if changed
3304
+ const header = this._rootEl?.querySelector('.sc-header h1');
3305
+ if (header && newOptions.headerTitle !== undefined) {
3306
+ header.textContent = this.options.headerTitle;
3307
+ }
3308
+
3309
+ // Update instruction if changed
3310
+ if (this._instructionEl && newOptions.instruction !== undefined) {
3311
+ this._instructionEl.textContent = this.options.instruction;
3312
+ }
3313
+
3314
+ // Update coin icon if changed
3315
+ if (this._coinEl && newOptions.coinIcon !== undefined) {
3316
+ this._coinEl.textContent = this.options.coinIcon;
3317
+ }
3318
+
3319
+ // Update gift icon if changed
3320
+ if (this._giftIconEl && newOptions.giftIcon !== undefined) {
3321
+ this._giftIconEl.textContent = this.options.giftIcon;
3322
+ }
3323
+ }
3324
+
3325
+ /**
3326
+ * Announce a message to screen readers via a visually hidden aria-live region.
3327
+ * @param {string} message
3328
+ */
3329
+ _announceToScreenReader(message) {
3330
+ if (!_isBrowser) return;
3331
+ if (!this._liveRegion) {
3332
+ const el = document.createElement('div');
3333
+ el.setAttribute('aria-live', 'assertive');
3334
+ el.setAttribute('aria-atomic', 'true');
3335
+ el.setAttribute('role', 'status');
3336
+ el.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
3337
+ document.body.appendChild(el);
3338
+ this._liveRegion = el;
3339
+ }
3340
+ this._liveRegion.textContent = '';
3341
+ setTimeout(() => { if (this._liveRegion) this._liveRegion.textContent = message; }, 100);
3342
+ }
3343
+
3344
+ /**
3345
+ * Destroy the instance and clean up DOM/events.
3346
+ */
3347
+ destroy() {
3348
+ if (this._destroyed) return;
3349
+ this._destroyed = true;
3350
+
3351
+ // ── Cancel confetti animation frame ──
3352
+ if (this._confettiRafId) {
3353
+ cancelAnimationFrame(this._confettiRafId);
3354
+ this._confettiRafId = null;
3355
+ }
3356
+
3357
+ // ── Clear confetti burst timeouts ──
3358
+ if (this._confettiTimeouts) {
3359
+ this._confettiTimeouts.forEach(clearTimeout);
3360
+ this._confettiTimeouts = [];
3361
+ }
3362
+
3363
+ // ── Remove canvas event listeners ──
3364
+ if (this._canvasEl) {
3365
+ this._canvasEl.removeEventListener('mousedown', this._boundStartScratch);
3366
+ this._canvasEl.removeEventListener('mousemove', this._boundMoveScratch);
3367
+ this._canvasEl.removeEventListener('mouseup', this._boundEndScratch);
3368
+ this._canvasEl.removeEventListener('mouseleave', this._boundEndScratch);
3369
+ this._canvasEl.removeEventListener('touchstart', this._boundStartScratch);
3370
+ this._canvasEl.removeEventListener('touchmove', this._boundMoveScratch);
3371
+ this._canvasEl.removeEventListener('touchend', this._boundEndScratch);
3372
+ this._canvasEl.removeEventListener('touchcancel', this._boundEndScratch);
3373
+ }
3374
+
3375
+ // ── Remove coin listeners ──
3376
+ if (this._coinEl && this._boundStartCoinDrag) {
3377
+ this._coinEl.removeEventListener('mousedown', this._boundStartCoinDrag);
3378
+ this._coinEl.removeEventListener('touchstart', this._boundStartCoinDrag);
3379
+ }
3380
+
3381
+ // ── Remove global document listeners (prevents memory leak) ──
3382
+ if (this._boundDocMouseMove) {
3383
+ document.removeEventListener('mousemove', this._boundDocMouseMove);
3384
+ document.removeEventListener('mouseup', this._boundDocMouseUp);
3385
+ document.removeEventListener('touchmove', this._boundDocTouchMove);
3386
+ document.removeEventListener('touchend', this._boundDocTouchEnd);
3387
+ document.removeEventListener('touchcancel', this._boundDocTouchCancel);
3388
+ }
3389
+
3390
+ // ── Remove modal event listeners ──
3391
+ if (this._modalEl && this._boundModalClick) {
3392
+ this._modalEl.removeEventListener('click', this._boundModalClick);
3393
+ }
3394
+ if (this._claimBtnEl && this._boundClaimClick) {
3395
+ this._claimBtnEl.removeEventListener('click', this._boundClaimClick);
3396
+ }
3397
+
3398
+ // ── Remove global keyboard listener ──
3399
+ if (this._boundDocKeydown) {
3400
+ document.removeEventListener('keydown', this._boundDocKeydown);
3401
+ }
3402
+
3403
+ // ── Remove live-region ──
3404
+ if (this._liveRegion?.parentNode) {
3405
+ this._liveRegion.parentNode.removeChild(this._liveRegion);
3406
+ }
3407
+
3408
+ // Disconnect resize observer
3409
+ if (this._resizeObserver) {
3410
+ this._resizeObserver.disconnect();
3411
+ this._resizeObserver = null;
3412
+ }
3413
+
3414
+ // Remove modal
3415
+ if (this._modalEl && this._modalEl.parentNode) {
3416
+ this._modalEl.parentNode.removeChild(this._modalEl);
3417
+ }
3418
+
3419
+ // Remove root
3420
+ if (this._rootEl && this._rootEl.parentNode) {
3421
+ this._rootEl.parentNode.removeChild(this._rootEl);
3422
+ }
3423
+
3424
+ // Clear all references to prevent memory leaks
3425
+ this._rootEl = null;
3426
+ this._zoneEl = null;
3427
+ this._canvasEl = null;
3428
+ this._ctx = null;
3429
+ this._coinEl = null;
3430
+ this._giftIconEl = null;
3431
+ this._modalEl = null;
3432
+ this._confettiCanvasEl = null;
3433
+ this._prizeContentEl = null;
3434
+ this._instructionEl = null;
3435
+ this._prize = null;
3436
+ this._lastPoint = null;
3437
+ this._liveRegion = null;
3438
+ this._claimBtnEl = null;
3439
+ }
3440
+
3441
+ /** Get the current prize object */
3442
+ get prize() {
3443
+ return this._prize;
3444
+ }
3445
+ }
3446
+
3447
+ /* ------------------------------------------------------------------ */
3448
+ /* Static factory */
3449
+ /* ------------------------------------------------------------------ */
3450
+
3451
+ const ScratchCard = {
3452
+ /**
3453
+ * Create and return a new ScratchCardInstance.
3454
+ * @param {ScratchCardOptions} opts
3455
+ * @returns {ScratchCardInstance}
3456
+ */
3457
+ init(opts) {
3458
+ return new ScratchCardInstance(opts);
3459
+ },
3460
+
3461
+ /** The underlying class – useful for `instanceof` checks or subclassing. */
3462
+ Instance: ScratchCardInstance,
3463
+
3464
+ /** Current SDK version */
3465
+ VERSION: '0.1.0-alpha.1',
3466
+
3467
+ /** Export defaults for reference */
3468
+ DEFAULTS: DEFAULTS,
3469
+ };
3470
+
3471
+ /**
3472
+ * ScratchCard React Component (ES6 class-based wrapper)
3473
+ *
3474
+ * @example
3475
+ * import { ScratchCardReact } from 'spin-wheel-sdk/react';
3476
+ *
3477
+ * <ScratchCardReact
3478
+ * ref={cardRef}
3479
+ * prize={{ name: 'iPhone 16', icon: '📱', label: 'Congratulations!' }}
3480
+ * onReveal={(prize) => console.log(prize)}
3481
+ * />
3482
+ */
3483
+
3484
+
3485
+ /**
3486
+ * All supported prop keys for ScratchCardReact component.
3487
+ * Used for copying props to SDK options.
3488
+ */
3489
+ const PROP_KEYS = Object.freeze([
3490
+ // Core
3491
+ 'prize',
3492
+
3493
+ // Header UI
3494
+ 'headerTitle',
3495
+ 'headerTitleColor',
3496
+
3497
+ // Instruction
3498
+ 'instruction',
3499
+ 'instructionColor',
3500
+
3501
+ // Scratch Behavior
3502
+ 'revealThreshold',
3503
+ 'brushSize',
3504
+
3505
+ // Coin
3506
+ 'coinSize',
3507
+ 'showCoin',
3508
+ 'coinIcon',
3509
+ 'coinGradientStart',
3510
+ 'coinGradientEnd',
3511
+
3512
+ // Card Appearance
3513
+ 'cardBackground',
3514
+ 'cardShadow',
3515
+ 'cardBorderRadius',
3516
+
3517
+ // Scratch Zone
3518
+ 'scratchZoneBackground',
3519
+ 'scratchZoneShadow',
3520
+ 'scratchZoneBorderRadius',
3521
+
3522
+ // Scratch Layer
3523
+ 'scratchLayerColor',
3524
+ 'scratchLayerSparkles',
3525
+ 'scratchLayerSparkleCount',
3526
+
3527
+ // Prize Display
3528
+ 'prizeTextColor',
3529
+ 'prizeNameColor',
3530
+ 'prizeIconBackground',
3531
+
3532
+ // Gift Icon
3533
+ 'showGiftIcon',
3534
+ 'giftIcon',
3535
+ 'giftIconBackground',
3536
+
3537
+ // Modal
3538
+ 'showModal',
3539
+ 'modalTitle',
3540
+ 'modalTitleColor',
3541
+ 'modalButtonText',
3542
+ 'modalButtonColor',
3543
+ 'modalButtonTextColor',
3544
+ 'modalBackdropBlur',
3545
+
3546
+ // Confetti
3547
+ 'confettiEnabled',
3548
+ 'confettiColors',
3549
+ 'confettiCount',
3550
+ 'confettiDuration',
3551
+
3552
+ // Animation
3553
+ 'animationType',
3554
+ 'animationDuration',
3555
+
3556
+ // Theme
3557
+ 'theme',
3558
+ ]);
3559
+
3560
+ /**
3561
+ * Creates the ScratchCardReact component using the provided React instance.
3562
+ * This factory pattern avoids bundling React into the SDK.
3563
+ *
3564
+ * @param {typeof import('react')} React – the React module
3565
+ * @returns {{ ScratchCardReact: React.ForwardRefExoticComponent }}
3566
+ */
3567
+ function createScratchCardReact(React) {
3568
+ const { useEffect, useRef, useImperativeHandle, forwardRef, createElement } = React;
3569
+
3570
+ const ScratchCardReact = forwardRef(function ScratchCardReact(props, ref) {
3571
+ const containerRef = useRef(null);
3572
+ const instanceRef = useRef(null);
3573
+
3574
+ // Keep latest callbacks in a ref so they're always fresh
3575
+ const callbacksRef = useRef({});
3576
+ callbacksRef.current.onScratchStart = props.onScratchStart;
3577
+ callbacksRef.current.onScratchProgress = props.onScratchProgress;
3578
+ callbacksRef.current.onReveal = props.onReveal;
3579
+ callbacksRef.current.onClaim = props.onClaim;
3580
+
3581
+ // Mount the SDK once, destroy on unmount (SSR-safe)
3582
+ useEffect(() => {
3583
+ if (!containerRef.current || typeof window === 'undefined') return;
3584
+
3585
+ const opts = {};
3586
+
3587
+ // Copy only defined props
3588
+ PROP_KEYS.forEach((k) => {
3589
+ if (props[k] !== undefined) opts[k] = props[k];
3590
+ });
3591
+
3592
+ opts.container = containerRef.current;
3593
+
3594
+ // Wrap callbacks so they always point to latest prop
3595
+ opts.onScratchStart = () => callbacksRef.current.onScratchStart?.();
3596
+ opts.onScratchProgress = (percent) => callbacksRef.current.onScratchProgress?.(percent);
3597
+ opts.onReveal = (prize) => callbacksRef.current.onReveal?.(prize);
3598
+ opts.onClaim = (prize) => callbacksRef.current.onClaim?.(prize);
3599
+
3600
+ try {
3601
+ instanceRef.current = ScratchCard.init(opts);
3602
+ } catch (err) {
3603
+ console.error('[ScratchCardReact] Failed to initialize ScratchCard:', err);
3604
+ }
3605
+
3606
+ return () => {
3607
+ try {
3608
+ instanceRef.current?.destroy();
3609
+ } catch (_) { /* noop */ }
3610
+ instanceRef.current = null;
3611
+ };
3612
+ }, []); // mount-only
3613
+
3614
+ // Live-update prize when the prop changes
3615
+ useEffect(() => {
3616
+ if (instanceRef.current && props.prize) {
3617
+ try {
3618
+ instanceRef.current.updatePrize(props.prize);
3619
+ } catch (_) { /* ignore on first mount */ }
3620
+ }
3621
+ }, [props.prize]);
3622
+
3623
+ // Expose imperative handle
3624
+ useImperativeHandle(ref, () => ({
3625
+ /** Programmatically reveal the prize */
3626
+ reveal() { instanceRef.current?.reveal(); },
3627
+
3628
+ /** Hide the modal */
3629
+ hideModal() { instanceRef.current?.hideModal(); },
3630
+
3631
+ /** Show the modal */
3632
+ showModal() { instanceRef.current?.showModal(); },
3633
+
3634
+ /** Reset the scratch card */
3635
+ reset() { instanceRef.current?.reset(); },
3636
+
3637
+ /** Update prize at runtime */
3638
+ updatePrize(prize) { instanceRef.current?.updatePrize(prize); },
3639
+
3640
+ /** Update options at runtime */
3641
+ updateOptions(opts) { instanceRef.current?.updateOptions(opts); },
3642
+
3643
+ /** Get the current scratch percentage */
3644
+ get scratchPercent() { return instanceRef.current?.scratchPercent; },
3645
+
3646
+ /** Get whether the prize has been revealed */
3647
+ get hasRevealed() { return instanceRef.current?.hasRevealed; },
3648
+
3649
+ /** Get the current prize */
3650
+ get prize() { return instanceRef.current?.prize; },
3651
+
3652
+ /** Access the raw ScratchCardInstance */
3653
+ get instance() { return instanceRef.current; },
3654
+ }), []);
3655
+
3656
+ return createElement('div', {
3657
+ ref: containerRef,
3658
+ className: props.className || '',
3659
+ style: props.style || {},
3660
+ });
3661
+ });
3662
+
3663
+ ScratchCardReact.displayName = 'ScratchCardReact';
3664
+ return ScratchCardReact;
3665
+ }
3666
+
3667
+ /* ------------------------------------------------------------------
3668
+ Auto-detect React on `window` for UMD / CDN usage.
3669
+ In bundled ESM builds consumers call createScratchCardReact(React).
3670
+ ------------------------------------------------------------------ */
3671
+ exports.ScratchCardReact = null;
3672
+
3673
+ if (typeof window !== 'undefined' && window.React) {
3674
+ exports.ScratchCardReact = createScratchCardReact(window.React);
3675
+ }
3676
+
3677
+ exports.ScratchCard = ScratchCard;
3678
+ exports.ScratchCardInstance = ScratchCardInstance;
3679
+ exports.SpinWheel = SpinWheel;
3680
+ exports.SpinWheelInstance = SpinWheelInstance;
3681
+ exports.createScratchCardReact = createScratchCardReact;
3682
+ exports.createSpinWheelReact = createSpinWheelReact;
3683
+
3684
+ }));
3685
+ //# sourceMappingURL=spin-wheel-sdk.umd.js.map