@pcampus/reward-widget 0.1.0 → 0.2.0

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.
@@ -1,9 +1,22 @@
1
+ import { mixPrimary, resolveWidgetChrome } from "./chrome.js";
2
+ import { showCouponSuccess } from "./coupon-success.js";
1
3
  import { textEscape } from "./types.js";
2
4
  export const MIN_WHEEL_SLICES = 6;
3
5
  export const WHEEL_CX = 160;
4
6
  export const WHEEL_CY = 160;
5
7
  export const WHEEL_R = 160;
6
- export const WHEEL_LABEL_RADIUS = 104;
8
+ // 60-30-10 Design System Palette (Pcampus Studio Minimal Dark)
9
+ export const PCAMPUS_SURFACE_CARD = "#1c1c1b";
10
+ export const PCAMPUS_SURFACE_SLATE = "#262624";
11
+ export const PCAMPUS_SURFACE_ONYX = "#181817";
12
+ export const PCAMPUS_ACCENT_BLUE = "#4b7fd4";
13
+ export const PCAMPUS_ACCENT_GOLD = "#e8a96a";
14
+ export const PCAMPUS_BORDER_SUBTLE = "rgba(240, 239, 237, 0.12)";
15
+ export const DEFAULT_PCAMPUS_SLICE_COLORS = [
16
+ PCAMPUS_SURFACE_CARD,
17
+ PCAMPUS_SURFACE_SLATE,
18
+ PCAMPUS_SURFACE_ONYX,
19
+ ];
7
20
  export function polar(cx, cy, r, deg) {
8
21
  const rad = ((deg - 90) * Math.PI) / 180;
9
22
  return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
@@ -37,10 +50,12 @@ export function wheelSlices(rewards) {
37
50
  const list = valid.length > 0
38
51
  ? valid
39
52
  : [
40
- { name: "Special Reward" },
41
- { name: "Lucky Bonus" },
42
- { name: "100 Points" },
43
- { name: "50% Discount" },
53
+ { name: "ส่วนลด 50 บาท" },
54
+ { name: "รับโบนัส 100 คะแนน" },
55
+ { name: "คูปองฟรีค่าจัดส่ง" },
56
+ { name: "รับโบนัส 20 คะแนน" },
57
+ { name: "ส่วนลด 10%" },
58
+ { name: "ลองใหม่อีกครั้ง" },
44
59
  ];
45
60
  const counts = visualSliceCounts(list.length);
46
61
  const remaining = [...counts];
@@ -65,107 +80,317 @@ export function wheelSlices(rewards) {
65
80
  midDeg: (i + 0.5) * sweep,
66
81
  }));
67
82
  }
68
- function pegsMarkup() {
69
- const parts = [];
70
- const numPegs = 12;
71
- for (let i = 0; i < numPegs; i++) {
72
- const deg = (i * 360) / numPegs;
73
- const rad = ((deg - 90) * Math.PI) / 180;
74
- const px = WHEEL_CX + 140 * Math.cos(rad);
75
- const py = WHEEL_CY + 140 * Math.sin(rad);
76
- parts.push(`<circle cx="${px.toFixed(3)}" cy="${py.toFixed(3)}" r="4.5" fill="#f59e0b" stroke="#ffffff" stroke-width="1"/>`);
83
+ export function sliceFaIcon(name) {
84
+ const lower = name.toLowerCase();
85
+ if (lower.includes("คะแนน") || lower.includes("point") || lower.includes("score")) {
86
+ return { style: "regular", icon: "fa-copyright" };
77
87
  }
78
- return parts.join("");
88
+ if (lower.includes("คูปอง") ||
89
+ lower.includes("coupon") ||
90
+ lower.includes("voucher") ||
91
+ lower.includes("ส่วนลด")) {
92
+ return { style: "solid", icon: "fa-ticket" };
93
+ }
94
+ if (lower.includes("ไม่ได้") ||
95
+ lower.includes("miss") ||
96
+ lower.includes("luck") ||
97
+ lower.includes("sorry") ||
98
+ lower.includes("sad") ||
99
+ lower.includes("เสียใจ") ||
100
+ lower.includes("ลองใหม่")) {
101
+ return { style: "regular", icon: "fa-face-frown-open" };
102
+ }
103
+ return { style: "solid", icon: "fa-gift" };
104
+ }
105
+ export function sliceLabelLines(name) {
106
+ const trimmed = name.replace(/\r/g, "").trim();
107
+ if (!trimmed)
108
+ return [];
109
+ if (trimmed.includes("\n")) {
110
+ return trimmed.split("\n").map((line) => line.trim()).filter(Boolean).slice(0, 3);
111
+ }
112
+ const bonus = trimmed.match(/^(รับโบนัส)\s+(\d+)\s+(คะแนน)$/u);
113
+ if (bonus)
114
+ return [bonus[1], bonus[2], bonus[3]];
115
+ const coupon = trimmed.match(/^(คูปองส่วนลด)\s+(.+)$/u);
116
+ if (coupon)
117
+ return [coupon[1], coupon[2]];
118
+ const words = trimmed.split(/\s+/);
119
+ if (words.length <= 1)
120
+ return [trimmed];
121
+ if (words.length === 2)
122
+ return [words[0], words[1]];
123
+ return [words[0], words.slice(1).join(" ")];
124
+ }
125
+ function isDarkHex(hex) {
126
+ const clean = hex.replace("#", "");
127
+ if (clean.length !== 6 && clean.length !== 3)
128
+ return true;
129
+ const r = parseInt(clean.length === 3 ? clean[0] + clean[0] : clean.slice(0, 2), 16);
130
+ const g = parseInt(clean.length === 3 ? clean[1] + clean[1] : clean.slice(2, 4), 16);
131
+ const b = parseInt(clean.length === 3 ? clean[2] + clean[2] : clean.slice(4, 6), 16);
132
+ const yiq = (r * 299 + g * 587 + b * 114) / 1000;
133
+ return yiq < 140;
79
134
  }
80
135
  export function renderSpinWheelMarkup(rewards = [], theme) {
81
136
  const slices = wheelSlices(rewards);
137
+ const { primary: brandPrimary, surface, border: chromeBorder } = resolveWidgetChrome(theme);
82
138
  const colors = theme?.sliceColors && theme.sliceColors.length === 3
83
139
  ? theme.sliceColors
84
- : ["#ffffff", "#dbeafe", "#1e3a8a"];
85
- const borderColor = theme?.wheelBorderColor || "#233044";
140
+ : [...DEFAULT_PCAMPUS_SLICE_COLORS];
141
+ const borderColor = theme?.wheelBorderColor?.trim() || chromeBorder;
142
+ const hubFill = theme?.hubBackgroundColor?.trim()
143
+ ? textEscape(theme.hubBackgroundColor.trim())
144
+ : "linear-gradient(145deg, #242731 0%, #1c1c1b 60%, #141413 100%)";
145
+ // Initial 3 o'clock (90 deg) slice
146
+ const initialSlice = slices.find((s) => 90 >= s.startDeg && 90 < s.endDeg) || slices[0];
147
+ const initialIcon = initialSlice ? sliceFaIcon(initialSlice.name) : { style: "solid", icon: "fa-gift" };
86
148
  const slicesSvg = slices
87
149
  .map((slice, i) => {
88
150
  const color = colors[i % colors.length];
89
- const isDark = color === "#1e3a8a" || color === "#1e293b" || color === "#0f172a";
90
- const textColor = isDark ? "#ffffff" : "#0f172a";
151
+ const isDark = isDarkHex(color);
152
+ const textColor = isDark ? "#f0efed" : "#0d0d0c";
153
+ const iconColor = brandPrimary;
91
154
  const d = slicePath(WHEEL_CX, WHEEL_CY, WHEEL_R, slice.startDeg, slice.endDeg);
92
- const label = slice.name.length > 12 ? slice.name.slice(0, 10) + "…" : slice.name;
155
+ const lines = sliceLabelLines(slice.name);
156
+ const fa = sliceFaIcon(slice.name);
157
+ let textMarkup = "";
158
+ if (lines.length === 1) {
159
+ textMarkup = `<text x="94" y="0" text-anchor="middle" dominant-baseline="central" font-size="12" font-weight="700" fill="${textColor}" letter-spacing="-0.01em">${textEscape(lines[0])}</text>`;
160
+ }
161
+ else if (lines.length === 2) {
162
+ textMarkup = `
163
+ <text x="94" y="-7" text-anchor="middle" dominant-baseline="central" font-size="10" font-weight="600" fill="${textColor}" letter-spacing="-0.01em">${textEscape(lines[0])}</text>
164
+ <text x="94" y="7" text-anchor="middle" dominant-baseline="central" font-size="13" font-weight="800" fill="${textColor}" letter-spacing="-0.01em">${textEscape(lines[1])}</text>
165
+ `;
166
+ }
167
+ else {
168
+ textMarkup = `
169
+ <text x="94" y="-9" text-anchor="middle" dominant-baseline="central" font-size="9" font-weight="600" fill="${textColor}" letter-spacing="-0.01em">${textEscape(lines[0])}</text>
170
+ <text x="94" y="2" text-anchor="middle" dominant-baseline="central" font-size="13" font-weight="800" fill="${textColor}" letter-spacing="-0.01em">${textEscape(lines[1])}</text>
171
+ <text x="94" y="12" text-anchor="middle" dominant-baseline="central" font-size="9" font-weight="600" fill="${textColor}" letter-spacing="-0.01em">${textEscape(lines[2])}</text>
172
+ `;
173
+ }
93
174
  return `
94
175
  <g class="pcampus-spin-slice-group">
95
- <path d="${d}" fill="${color}"/>
96
- <g transform="translate(${WHEEL_CX}, ${WHEEL_CY}) rotate(${slice.midDeg}) translate(0, ${-WHEEL_LABEL_RADIUS}) rotate(${-slice.midDeg})">
97
- <text x="0" y="0" text-anchor="middle" dominant-baseline="central" font-size="11" font-weight="900" fill="${textColor}" style="filter:drop-shadow(0 1px 2px rgba(0,0,0,0.3));">
98
- ${textEscape(label)}
99
- </text>
176
+ <path d="${d}" fill="${color}" stroke="rgba(240, 239, 237, 0.06)" stroke-width="1"/>
177
+ <!-- Text sits directly along the radial ray pointing outward from hub to rim -->
178
+ <g transform="translate(${WHEEL_CX}, ${WHEEL_CY}) rotate(${slice.midDeg - 90})">
179
+ <g class="spin-label-radial" style="transition:opacity 350ms ease;">
180
+ <foreignObject x="126" y="-10" width="22" height="20">
181
+ <div xmlns="http://www.w3.org/1999/xhtml" class="spin-slice-icon" style="color:${iconColor};display:flex;align-items:center;justify-content:center;font-size:13px;line-height:1;width:100%;height:100%;">
182
+ <i class="fa-${fa.style} ${fa.icon}"></i>
183
+ </div>
184
+ </foreignObject>
185
+ ${textMarkup}
186
+ </g>
100
187
  </g>
101
188
  </g>
102
189
  `;
103
190
  })
104
191
  .join("");
105
192
  return `
106
- <div class="pcampus-reward-spin-wrap" style="position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;padding:0.5rem;box-sizing:border-box;">
107
- <div class="pcampus-spin-stage" style="position:relative;width:18rem;height:18rem;max-width:90vw;user-select:none;display:flex;align-items:center;justify-content:center;">
108
- <div class="pcampus-spin-wheel-disc" style="position:relative;width:100%;height:100%;border-radius:50%;border:6px solid ${borderColor};overflow:hidden;box-shadow:0 30px 70px rgba(0,0,0,0.6);background:#0f172a;">
193
+ <style>
194
+ @keyframes pcampus-idle-spin {
195
+ from { transform: rotate(0deg); }
196
+ to { transform: rotate(360deg); }
197
+ }
198
+ .pcampus-spin-wheel-disc.is-idle {
199
+ animation: pcampus-idle-spin 50s linear infinite;
200
+ }
201
+ .pcampus-spin-hub-btn:hover {
202
+ transform: translate(-50%, -50%) scale(1.06) !important;
203
+ }
204
+ .pcampus-spin-hub-btn:active {
205
+ transform: translate(-50%, -50%) scale(0.96) !important;
206
+ }
207
+ </style>
208
+ <div class="pcampus-reward-spin-wrap" style="position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;max-width:24rem;box-sizing:border-box;">
209
+
210
+ <!-- Active Live Pointer HUD: Shows reward tracked by the right pointer -->
211
+ <div class="pcampus-spin-live-ticker" aria-live="polite" style="display:flex;align-items:center;gap:0.45rem;padding:0.35rem 0.95rem;border-radius:9999px;border:1px solid rgba(240, 239, 237, 0.12);background:rgba(28, 28, 27, 0.85);backdrop-filter:blur(8px);font-size:0.8rem;font-weight:600;color:#f0efed;margin-bottom:1.25rem;min-height:1.85rem;transition:all 150ms ease;">
212
+ <span class="pcampus-ticker-icon" style="color:${brandPrimary};font-size:0.85rem;display:flex;align-items:center;">
213
+ <i class="fa-${initialIcon.style} ${initialIcon.icon}"></i>
214
+ </span>
215
+ <span class="pcampus-ticker-text" style="letter-spacing:-0.01em;">${textEscape(initialSlice?.name || "พร้อมหมุนลุ้นรางวัล")}</span>
216
+ </div>
217
+
218
+ <div class="pcampus-spin-stage" style="position:relative;width:min(84vw, 22rem);height:min(84vw, 22rem);max-width:350px;max-height:350px;user-select:none;display:flex;align-items:center;justify-content:center;">
219
+
220
+ <!-- Wheel Disc with Idle Slow Continuous Spin -->
221
+ <div class="pcampus-spin-wheel-disc is-idle" style="position:relative;width:100%;height:100%;border-radius:50%;border:2px solid ${borderColor};overflow:hidden;background:${surface};">
109
222
  <svg viewBox="0 0 320 320" style="width:100%;height:100%;display:block;">
110
223
  ${slicesSvg}
111
- ${pegsMarkup()}
112
224
  </svg>
113
225
  </div>
114
226
 
115
- <button type="button" class="pcampus-spin-hub-btn" aria-label="Spin wheel" style="position:absolute;top:50%;left:50%;transform:translate(-50%, calc(-50% - 0.42rem));width:6.4rem;height:7.2rem;background:transparent;border:none;padding:0;cursor:pointer;outline:none;z-index:30;display:flex;align-items:center;justify-content:center;transition:transform 160ms ease;">
116
- <svg viewBox="0 0 160 180" style="width:100%;height:100%;overflow:visible;filter:drop-shadow(0 12px 24px rgba(0,0,0,0.7));" fill="none">
227
+ <!-- Right Pin Pointer (3 o'clock position pointing left) -->
228
+ <div class="pcampus-spin-pointer-right" style="position:absolute;right:-12px;top:50%;transform:translateY(-50%);z-index:35;display:flex;align-items:center;pointer-events:none;filter:drop-shadow(-2px 0 6px rgba(0,0,0,0.5));">
229
+ <svg width="30" height="36" viewBox="0 0 30 36" fill="none">
117
230
  <defs>
118
- <linearGradient id="pcampus-lib-hub-grad" x1="0%" y1="0%" x2="0%" y2="100%">
119
- <stop offset="0%" stop-color="#1e293b"/>
120
- <stop offset="45%" stop-color="#0f172a"/>
121
- <stop offset="100%" stop-color="#060911"/>
231
+ <linearGradient id="pointer-right-grad" x1="100%" y1="0%" x2="0%" y2="0%">
232
+ <stop offset="0%" stop-color="${brandPrimary}"/>
233
+ <stop offset="50%" stop-color="${brandPrimary}"/>
234
+ <stop offset="100%" stop-color="${brandPrimary}"/>
122
235
  </linearGradient>
123
236
  </defs>
124
- <path d="M 78.5 38 Q 80 34 81.5 38 L 100 60.17 A 50 50 0 1 1 60 60.17 Z" fill="url(#pcampus-lib-hub-grad)"/>
237
+ <path d="M 4 18 L 26 5 L 26 31 Z" fill="url(#pointer-right-grad)" stroke="#ffffff" stroke-width="1.5" stroke-linejoin="round"/>
238
+ <circle cx="20" cy="18" r="2.5" fill="#ffffff"/>
125
239
  </svg>
126
- <div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;color:#ffffff;padding-top:1.25rem;pointer-events:none;">
127
- <span style="font-size:1.1rem;font-weight:900;letter-spacing:0.05em;color:#f59e0b;text-shadow:0 2px 4px rgba(0,0,0,0.5);line-height:1.1;">SPIN</span>
128
- <span style="font-size:0.55rem;font-weight:800;letter-spacing:0.12em;color:#94a3b8;text-transform:uppercase;margin-top:2px;">1 TICKET</span>
129
- </div>
240
+ </div>
241
+
242
+ <!-- Center Circular Hub Button (Logo-Only when logo is provided, Clean & Minimal) -->
243
+ <button type="button" class="pcampus-spin-hub-btn" aria-label="Spin wheel" style="position:absolute;top:50%;left:50%;transform:translate(-50%, -50%);width:6.8rem;height:6.8rem;border-radius:50%;background:${hubFill};border:2.5px solid ${brandPrimary};box-shadow:0 0 0 5px ${mixPrimary(brandPrimary, 18)}, 0 8px 24px rgba(0,0,0,0.6);cursor:pointer;outline:none;z-index:30;display:flex;align-items:center;justify-content:center;transition:all 180ms cubic-bezier(0.34, 1.56, 0.64, 1);overflow:hidden;padding:0.5rem;box-sizing:border-box;">
244
+ ${theme?.brandLogoUrl ? `
245
+ <div class="pcampus-hub-logo-only" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;pointer-events:none;">
246
+ <img src="${textEscape(theme.brandLogoUrl)}" alt="${textEscape(theme?.brandName || 'Brand Logo')}" style="max-width:85%;max-height:85%;object-fit:contain;filter:drop-shadow(0 2px 6px rgba(0,0,0,0.5));" />
247
+ </div>
248
+ ` : `
249
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;">
250
+ <span style="font-size:1.35rem;font-weight:900;letter-spacing:0.04em;color:#ffffff;line-height:1.1;">SPIN</span>
251
+ <span style="font-size:0.65rem;font-weight:700;letter-spacing:0.08em;color:${brandPrimary};text-transform:uppercase;margin-top:3px;">1 TICKET</span>
252
+ </div>
253
+ `}
130
254
  </button>
131
255
  </div>
132
- <div class="pcampus-spin-outcome" style="margin-top:1.2rem;font-size:0.9rem;font-weight:700;color:#ffffff;text-align:center;min-height:1.5rem;text-shadow:0 2px 4px rgba(0,0,0,0.3);">
256
+
257
+ <div class="pcampus-spin-outcome" style="margin-top:1.5rem;font-size:0.92rem;font-weight:600;color:#f0efed;text-align:center;min-height:2.25rem;padding:0.45rem 1.25rem;border-radius:9999px;border:1px solid rgba(240, 239, 237, 0.08);background:rgba(240, 239, 237, 0.03);">
133
258
  พร้อมหมุนสุ่มรางวัล!
134
259
  </div>
135
260
  </div>
136
261
  `;
137
262
  }
263
+ function ensureFontAwesomeLoaded() {
264
+ if (typeof document === "undefined")
265
+ return;
266
+ const id = "pcampus-fa-cdn";
267
+ if (!document.getElementById(id)) {
268
+ const link = document.createElement("link");
269
+ link.id = id;
270
+ link.rel = "stylesheet";
271
+ link.href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css";
272
+ document.head.appendChild(link);
273
+ }
274
+ }
138
275
  export function mountSpinWheel(target, rewards = [], theme, onAction) {
276
+ ensureFontAwesomeLoaded();
139
277
  target.innerHTML = renderSpinWheelMarkup(rewards, theme);
140
278
  const disc = target.querySelector(".pcampus-spin-wheel-disc");
141
279
  const btn = target.querySelector(".pcampus-spin-hub-btn");
142
280
  const outcome = target.querySelector(".pcampus-spin-outcome");
281
+ const tickerText = target.querySelector(".pcampus-ticker-text");
282
+ const tickerIcon = target.querySelector(".pcampus-ticker-icon");
283
+ const sliceLabels = target.querySelectorAll(".spin-label-radial");
284
+ const slices = wheelSlices(rewards);
285
+ const { primary: brandPrimary } = resolveWidgetChrome(theme);
143
286
  let isSpinning = false;
144
- let rotation = 0;
287
+ let rafId = null;
288
+ let lastActiveIndex = -1;
289
+ function updateLiveTicker() {
290
+ if (!disc)
291
+ return;
292
+ try {
293
+ const transform = window.getComputedStyle(disc).transform;
294
+ if (transform && transform !== "none") {
295
+ const matrix = new DOMMatrix(transform);
296
+ const currentDeg = (Math.round(Math.atan2(matrix.b, matrix.a) * (180 / Math.PI)) + 360) % 360;
297
+ // Pointer is at 3 o'clock (90 degrees)
298
+ const pointerDeg = (90 - currentDeg + 360) % 360;
299
+ const activeIndex = slices.findIndex((s) => pointerDeg >= s.startDeg && pointerDeg < s.endDeg);
300
+ if (activeIndex >= 0 && activeIndex !== lastActiveIndex) {
301
+ lastActiveIndex = activeIndex;
302
+ const currentSlice = slices[activeIndex];
303
+ if (currentSlice && tickerText) {
304
+ tickerText.textContent = currentSlice.name;
305
+ if (tickerIcon) {
306
+ const fa = sliceFaIcon(currentSlice.name);
307
+ tickerIcon.innerHTML = `<i class="fa-${fa.style} ${fa.icon}"></i>`;
308
+ }
309
+ }
310
+ }
311
+ }
312
+ }
313
+ catch { }
314
+ rafId = requestAnimationFrame(updateLiveTicker);
315
+ }
316
+ // Start continuous ticker tracking during idle rotation
317
+ rafId = requestAnimationFrame(updateLiveTicker);
145
318
  if (btn && disc) {
146
319
  btn.addEventListener("click", () => {
147
320
  if (isSpinning)
148
321
  return;
149
322
  isSpinning = true;
150
323
  btn.disabled = true;
324
+ btn.style.opacity = "0.7";
325
+ btn.style.cursor = "not-allowed";
151
326
  if (outcome)
152
327
  outcome.textContent = "กำลังหมุนวงล้อ…";
153
- const randomDegrees = 1800 + Math.floor(Math.random() * 1440);
154
- rotation += randomDegrees;
155
- disc.style.transition = "transform 4500ms cubic-bezier(0.15, 0.9, 0.2, 1)";
156
- disc.style.transform = `rotate(${rotation}deg)`;
328
+ // 1. Capture current rotational angle seamlessly from idle spin
329
+ let currentDeg = 0;
330
+ try {
331
+ const transform = window.getComputedStyle(disc).transform;
332
+ if (transform && transform !== "none") {
333
+ const matrix = new DOMMatrix(transform);
334
+ currentDeg = (Math.round(Math.atan2(matrix.b, matrix.a) * (180 / Math.PI)) + 360) % 360;
335
+ }
336
+ }
337
+ catch {
338
+ currentDeg = 0;
339
+ }
340
+ // 2. Cancel idle CSS animation seamlessly without jump
341
+ disc.classList.remove("is-idle");
342
+ disc.style.animation = "none";
343
+ disc.style.transform = `rotate(${currentDeg}deg)`;
344
+ void disc.offsetHeight;
345
+ // 3. Fade labels during high-speed spin
346
+ sliceLabels.forEach((label) => {
347
+ label.style.opacity = "0.35";
348
+ });
349
+ // 4. Calculate high-speed target rotation
350
+ const extraDegrees = 2160 + Math.floor(Math.random() * 1440);
351
+ const targetDeg = currentDeg + extraDegrees;
352
+ disc.style.transition = "transform 4800ms cubic-bezier(0.12, 0.88, 0.22, 1)";
353
+ disc.style.transform = `rotate(${targetDeg}deg)`;
354
+ // 5. Restore label opacity smoothly during deceleration
355
+ setTimeout(() => {
356
+ sliceLabels.forEach((label) => {
357
+ label.style.opacity = "1";
358
+ });
359
+ }, 2800);
360
+ // 6. On spin finish
157
361
  setTimeout(() => {
158
362
  isSpinning = false;
159
363
  btn.disabled = false;
160
- const slices = wheelSlices(rewards);
161
- const winningSlice = slices[0];
162
- if (outcome) {
163
- outcome.innerHTML = `🎉 ยินดีด้วย! คุณได้รับ: <strong style="color:#fcd34d;">${textEscape(winningSlice?.name || "รางวัลพิเศษ")}</strong>`;
364
+ btn.style.opacity = "1";
365
+ btn.style.cursor = "pointer";
366
+ // Determine final winning slice at 3 o'clock (90 deg)
367
+ const finalNormalizedDeg = (90 - (targetDeg % 360) + 360) % 360;
368
+ const winningSlice = slices.find((s) => finalNormalizedDeg >= s.startDeg && finalNormalizedDeg < s.endDeg) || slices[0];
369
+ if (tickerText && winningSlice) {
370
+ tickerText.textContent = winningSlice.name;
371
+ if (tickerIcon) {
372
+ const fa = sliceFaIcon(winningSlice.name);
373
+ tickerIcon.innerHTML = `<i class="fa-${fa.style} ${fa.icon}"></i>`;
374
+ }
375
+ }
376
+ if (outcome && winningSlice) {
377
+ outcome.innerHTML = `ยินดีด้วย! คุณได้รับ: <strong style="color:${brandPrimary};">${textEscape(winningSlice.name)}</strong>`;
378
+ }
379
+ if (winningSlice) {
380
+ const matched = rewards.find((r) => r.id === winningSlice.id || r.name === winningSlice.name);
381
+ showCouponSuccess({
382
+ rewardName: winningSlice.name,
383
+ couponCode: matched?.couponCode,
384
+ });
164
385
  }
165
- if (onAction) {
166
- onAction(winningSlice ? { name: winningSlice.name, id: winningSlice.id } : undefined);
386
+ if (onAction && winningSlice) {
387
+ onAction({
388
+ name: winningSlice.name,
389
+ id: winningSlice.id,
390
+ couponCode: rewards.find((r) => r.id === winningSlice.id || r.name === winningSlice.name)?.couponCode,
391
+ });
167
392
  }
168
- }, 4600);
393
+ }, 4900);
169
394
  });
170
395
  }
171
396
  }
@@ -7,12 +7,31 @@ export interface WidgetRewardItem {
7
7
  description?: string;
8
8
  couponCode?: string;
9
9
  }
10
+ export type QuizItem = {
11
+ question: string;
12
+ options: string[];
13
+ };
14
+ export type QuizAnswerIndex = {
15
+ questionIndex: number;
16
+ optionIndex: number;
17
+ };
18
+ export type QuizContent = {
19
+ heading?: string;
20
+ items?: QuizItem[];
21
+ question?: string;
22
+ options?: string[];
23
+ /** 0-based step to show first. Clamped to the resolved item list. */
24
+ initialStep?: number;
25
+ };
10
26
  export interface InteractiveWidgetOptions {
11
27
  widgetType: WidgetType;
12
28
  rewards?: WidgetRewardItem[];
13
29
  theme?: RewardWidgetTheme;
30
+ quiz?: QuizContent;
14
31
  title?: string;
15
32
  disabled?: boolean;
16
33
  onAction?: (reward?: WidgetRewardItem) => void;
34
+ /** Quiz only. Selected indexes at complete. Presentation only — no HTTP. */
35
+ onQuizComplete?: (answers: QuizAnswerIndex[]) => void;
17
36
  }
18
37
  export declare const textEscape: (value: string | undefined) => string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pcampus/reward-widget",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Public UI components and theme types for Pcampus Reward Widget integrations.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,7 +13,11 @@
13
13
  "import": "./dist/index.js"
14
14
  }
15
15
  },
16
- "files": ["dist", "README.md", "LICENSE"],
16
+ "files": [
17
+ "dist",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
17
21
  "publishConfig": {
18
22
  "access": "public"
19
23
  },
@@ -22,7 +26,12 @@
22
26
  "test": "npm run build && node --test",
23
27
  "pack:dry-run": "npm pack --dry-run"
24
28
  },
25
- "keywords": ["pcampus", "reward", "widget", "embed"],
29
+ "keywords": [
30
+ "pcampus",
31
+ "reward",
32
+ "widget",
33
+ "embed"
34
+ ],
26
35
  "devDependencies": {
27
36
  "typescript": "^5.7.0"
28
37
  }