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