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