@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.
- package/README.md +27 -4
- package/dist/index.d.ts +5 -1
- package/dist/index.js +1 -1
- package/dist/theme.d.ts +5 -0
- package/dist/theme.js +24 -0
- package/dist/widgets/chrome.d.ts +13 -0
- package/dist/widgets/chrome.js +25 -0
- package/dist/widgets/coupon-success.d.ts +13 -0
- package/dist/widgets/coupon-success.js +256 -0
- package/dist/widgets/index.d.ts +1 -0
- package/dist/widgets/index.js +5 -4
- package/dist/widgets/mystery-box.js +310 -16
- package/dist/widgets/pick-card.d.ts +3 -0
- package/dist/widgets/pick-card.js +195 -33
- package/dist/widgets/quiz.d.ts +13 -3
- package/dist/widgets/quiz.js +396 -47
- package/dist/widgets/scratch.d.ts +3 -0
- package/dist/widgets/scratch.js +347 -47
- package/dist/widgets/spin.d.ts +12 -1
- package/dist/widgets/spin.js +278 -53
- package/dist/widgets/types.d.ts +19 -0
- package/package.json +12 -3
package/dist/widgets/spin.js
CHANGED
|
@@ -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
|
-
|
|
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: "
|
|
41
|
-
{ name: "
|
|
42
|
-
{ name: "
|
|
43
|
-
{ name: "
|
|
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
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
: [
|
|
85
|
-
const borderColor = theme?.wheelBorderColor ||
|
|
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
|
|
90
|
-
const textColor = isDark ? "#
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
<
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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="
|
|
119
|
-
<stop offset="0%" stop-color="
|
|
120
|
-
<stop offset="
|
|
121
|
-
<stop offset="100%" stop-color="
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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(
|
|
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
|
-
},
|
|
393
|
+
}, 4900);
|
|
169
394
|
});
|
|
170
395
|
}
|
|
171
396
|
}
|
package/dist/widgets/types.d.ts
CHANGED
|
@@ -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.
|
|
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": [
|
|
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": [
|
|
29
|
+
"keywords": [
|
|
30
|
+
"pcampus",
|
|
31
|
+
"reward",
|
|
32
|
+
"widget",
|
|
33
|
+
"embed"
|
|
34
|
+
],
|
|
26
35
|
"devDependencies": {
|
|
27
36
|
"typescript": "^5.7.0"
|
|
28
37
|
}
|