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