@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,78 +1,375 @@
1
+ import { parseHexRgb, resolveWidgetChrome, scaleRgb } from "./chrome.js";
2
+ import { showCouponSuccess } from "./coupon-success.js";
1
3
  import { textEscape } from "./types.js";
4
+ const DEFAULT_SCRATCH_TEXT = "#f0efed";
5
+ /** Visible scratch face: Ticket color, or Foil color when ticket is unset. */
6
+ export function resolveScratchFoilTint(theme) {
7
+ const { primary } = resolveWidgetChrome(theme);
8
+ return theme?.hubBackgroundColor?.trim() || primary;
9
+ }
10
+ export function resolveScratchTextColor(theme) {
11
+ return theme?.textColor?.trim() || DEFAULT_SCRATCH_TEXT;
12
+ }
2
13
  export function renderScratchCardMarkup(rewards = [], theme) {
3
- const topReward = rewards[0]?.name || "Lucky Special Reward";
4
- const primary = theme?.brandPrimaryColor || "#2563eb";
14
+ const topReward = rewards[0]?.name || "ส่วนลด 100 บาท";
15
+ const { primary, surface, border } = resolveWidgetChrome(theme);
16
+ const ticket = theme?.hubBackgroundColor?.trim() || surface;
17
+ const ink = resolveScratchTextColor(theme);
5
18
  return `
6
- <div class="pcampus-scratch-widget-wrap" style="position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;width:100%;max-width:260px;padding:1rem;border-radius:1.25rem;border:2px solid rgba(255,255,255,0.15);background:rgba(15,23,42,0.85);box-shadow:0 20px 40px rgba(0,0,0,0.5);text-align:center;">
7
- <div style="position:relative;width:100%;height:7.5rem;display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:0.875rem;border:1px solid #475569;background:linear-gradient(135deg, #94a3b8 0%, #cbd5e1 50%, #64748b 100%);overflow:hidden;box-shadow:inset 0 2px 4px rgba(0,0,0,0.3);">
8
- <div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#1e293b;color:#f8fafc;padding:0.5rem;z-index:1;">
9
- <span style="font-size:10px;font-weight:700;color:#94a3b8;text-transform:uppercase;">YOU WON</span>
10
- <span style="font-size:14px;font-weight:900;color:#fcd34d;margin-top:2px;">${textEscape(topReward)}</span>
19
+ <style>
20
+ .pcampus-scratch-container {
21
+ position: relative;
22
+ width: 100%;
23
+ max-width: min(92vw, 34rem);
24
+ height: clamp(14rem, 42vw, 17rem);
25
+ background: ${ticket};
26
+ background-clip: padding-box;
27
+ border: 2px solid ${border};
28
+ border-radius: 18px;
29
+ overflow: hidden;
30
+ box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: center;
34
+ user-select: none;
35
+ box-sizing: border-box;
36
+ }
37
+ .pcampus-scratch-edge-left, .pcampus-scratch-edge-right {
38
+ position: absolute;
39
+ top: 0;
40
+ bottom: 0;
41
+ width: 14px;
42
+ background-image: radial-gradient(circle, #141413 6px, transparent 7px);
43
+ background-size: 16px 18px;
44
+ z-index: 10;
45
+ pointer-events: none;
46
+ }
47
+ .pcampus-scratch-edge-left { left: -7px; }
48
+ .pcampus-scratch-edge-right { right: -7px; }
49
+ .pcampus-scratch-under {
50
+ display: flex;
51
+ justify-content: center;
52
+ align-items: center;
53
+ width: 100%;
54
+ height: 100%;
55
+ padding: 0.75rem;
56
+ box-sizing: border-box;
57
+ }
58
+ .pcampus-scratch-inner-frame {
59
+ width: 100%;
60
+ height: 100%;
61
+ border: 1.5px dashed rgba(240, 239, 237, 0.18);
62
+ border-radius: 12px;
63
+ display: flex;
64
+ flex-direction: column;
65
+ justify-content: space-between;
66
+ align-items: center;
67
+ padding: 1rem 1.25rem;
68
+ box-sizing: border-box;
69
+ text-align: center;
70
+ position: relative;
71
+ }
72
+ .pcampus-scratch-header-row {
73
+ display: flex;
74
+ align-items: center;
75
+ justify-content: space-between;
76
+ width: 100%;
77
+ }
78
+ .pcampus-scratch-brand-badge {
79
+ display: flex;
80
+ align-items: center;
81
+ gap: 0.4rem;
82
+ font-size: clamp(0.65rem, 1.8vw, 0.75rem);
83
+ font-weight: 800;
84
+ color: ${ink};
85
+ letter-spacing: 0.06em;
86
+ text-transform: uppercase;
87
+ }
88
+ .pcampus-scratch-serial {
89
+ font-family: monospace;
90
+ font-size: clamp(0.62rem, 1.6vw, 0.72rem);
91
+ color: ${ink};
92
+ opacity: 0.72;
93
+ letter-spacing: 0.12em;
94
+ text-transform: uppercase;
95
+ }
96
+ .pcampus-scratch-prize-label {
97
+ font-size: clamp(0.72rem, 1.9vw, 0.82rem);
98
+ font-weight: 800;
99
+ color: ${ink};
100
+ text-transform: uppercase;
101
+ letter-spacing: 0.15em;
102
+ border-bottom: 2px dashed ${ink};
103
+ padding-bottom: 0.2rem;
104
+ }
105
+ .pcampus-scratch-prize-name {
106
+ font-size: clamp(1.35rem, 4.2vw, 1.95rem);
107
+ font-weight: 900;
108
+ color: ${ink};
109
+ line-height: 1.2;
110
+ letter-spacing: -0.02em;
111
+ z-index: 2;
112
+ }
113
+ .pcampus-scratch-watermark {
114
+ position: absolute;
115
+ top: 50%;
116
+ left: 50%;
117
+ transform: translate(-50%, -50%);
118
+ opacity: 0.12;
119
+ pointer-events: none;
120
+ max-width: 140px;
121
+ max-height: 90px;
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ z-index: 1;
126
+ }
127
+ .pcampus-scratch-barcode {
128
+ font-family: monospace;
129
+ font-size: clamp(0.85rem, 2.3vw, 1.1rem);
130
+ color: ${ink};
131
+ opacity: 0.4;
132
+ letter-spacing: 4px;
133
+ user-select: none;
134
+ }
135
+ .pcampus-scratch-canvas {
136
+ position: absolute;
137
+ inset: 0;
138
+ width: 100%;
139
+ height: 100%;
140
+ cursor: crosshair;
141
+ touch-action: none;
142
+ z-index: 5;
143
+ }
144
+ .pcampus-scratch-guide-hint {
145
+ position: absolute;
146
+ inset: 0;
147
+ z-index: 6;
148
+ pointer-events: none;
149
+ display: flex;
150
+ flex-direction: column;
151
+ align-items: center;
152
+ justify-content: center;
153
+ gap: 0.45rem;
154
+ transition: opacity 250ms ease;
155
+ }
156
+ .pcampus-scratch-center-emblem {
157
+ height: 38px;
158
+ max-width: 120px;
159
+ display: flex;
160
+ align-items: center;
161
+ justify-content: center;
162
+ filter: drop-shadow(0 3px 8px rgba(0,0,0,0.7));
163
+ margin-bottom: 2px;
164
+ }
165
+ </style>
166
+
167
+ <div class="pcampus-scratch-widget-wrap" style="position:relative;display:flex;flex-direction:column;align-items:center;width:100%;box-sizing:border-box;">
168
+ <div class="pcampus-scratch-container">
169
+ <div class="pcampus-scratch-edge-left"></div>
170
+ <div class="pcampus-scratch-edge-right"></div>
171
+
172
+ <div class="pcampus-scratch-under">
173
+ <div class="pcampus-scratch-inner-frame">
174
+ <div class="pcampus-scratch-header-row">
175
+ <div class="pcampus-scratch-brand-badge">
176
+ <span style="color:${ink};"><i class="fa-solid fa-ticket"></i></span>
177
+ <span>${textEscape(theme?.brandName || "OFFICIAL REWARD TICKET")}</span>
178
+ </div>
179
+ <div class="pcampus-scratch-serial">NO. #77291</div>
180
+ </div>
181
+
182
+ <!-- Center Stamped Brand Logo & Prize Section -->
183
+ <div style="display:flex;flex-direction:column;align-items:center;justify-content:center;z-index:2;">
184
+ ${theme?.brandLogoUrl ? `
185
+ <div style="height:26px;max-width:100px;display:flex;align-items:center;justify-content:center;margin-bottom:0.35rem;filter:drop-shadow(0 2px 5px rgba(0,0,0,0.5));">
186
+ <img src="${textEscape(theme.brandLogoUrl)}" alt="Brand Logo" style="height:100%;max-width:100%;object-fit:contain;" />
187
+ </div>
188
+ ` : `
189
+ <div class="pcampus-scratch-prize-label">WINNER PRIZE</div>
190
+ `}
191
+ <div class="pcampus-scratch-prize-name">${textEscape(topReward)}</div>
192
+ </div>
193
+
194
+ ${theme?.brandLogoUrl ? `
195
+ <div class="pcampus-scratch-watermark">
196
+ <img src="${textEscape(theme.brandLogoUrl)}" alt="Watermark" style="max-width:100%;max-height:100%;object-fit:contain;" />
197
+ </div>
198
+ ` : ''}
199
+
200
+ <div class="pcampus-scratch-barcode">||| | |||| | || | |||| ||</div>
201
+ </div>
11
202
  </div>
12
203
 
13
- <canvas class="pcampus-scratch-canvas" width="240" height="120" style="position:absolute;inset:0;width:100%;height:100%;z-index:2;cursor:crosshair;"></canvas>
204
+ <canvas class="pcampus-scratch-canvas" width="480" height="240"></canvas>
14
205
 
15
- <div class="pcampus-scratch-overlay-hint" style="position:absolute;inset:0;z-index:3;pointer-events:none;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;">
16
- <svg style="width:28px;height:28px;color:#f59e0b;filter:drop-shadow(0 2px 4px rgba(0,0,0,0.3));" viewBox="0 0 24 24" fill="none">
17
- <circle cx="12" cy="12" r="9" fill="#f59e0b" stroke="#d97706" stroke-width="1.5"/>
18
- <circle cx="12" cy="12" r="6" stroke="#b45309" stroke-width="1" stroke-dasharray="2 2"/>
19
- <path d="M12 8v8m-3-5h6" stroke="#ffffff" stroke-width="1.5" stroke-linecap="round"/>
20
- </svg>
21
- <span style="font-size:11px;font-weight:800;letter-spacing:0.05em;color:#0f172a;text-transform:uppercase;">SCRATCH HERE</span>
206
+ <!-- Center Foil Overlay Guide with Prominent Brand Logo -->
207
+ <div class="pcampus-scratch-guide-hint">
208
+ ${theme?.brandLogoUrl ? `
209
+ <div class="pcampus-scratch-center-emblem">
210
+ <img src="${textEscape(theme.brandLogoUrl)}" alt="Brand Logo" style="height:100%;max-width:100%;object-fit:contain;" />
211
+ </div>
212
+ ` : `
213
+ <div style="width:38px;height:38px;border-radius:50%;background:${primary};display:flex;align-items:center;justify-content:center;color:#141413;box-shadow:0 4px 12px rgba(0,0,0,0.5);">
214
+ <i class="fa-solid fa-coins" style="font-size:1.1rem;"></i>
215
+ </div>
216
+ `}
217
+ <span style="font-size:0.78rem;font-weight:800;letter-spacing:0.08em;color:${ink};text-transform:uppercase;background:rgba(20,20,19,0.9);padding:3px 14px;border-radius:9999px;border:1px solid rgba(240,239,237,0.18);box-shadow:0 2px 8px rgba(0,0,0,0.4);">SCRATCH HERE</span>
22
218
  </div>
23
219
  </div>
24
- <span style="margin-top:0.6rem;font-size:11px;font-weight:500;color:#94a3b8;">Scratch to reveal your lucky prize!</span>
220
+
221
+ <div style="margin-top:1rem;font-size:0.82rem;font-weight:600;color:${ink};opacity:0.78;display:flex;align-items:center;gap:0.4rem;">
222
+ <span><i class="fa-solid fa-hand-pointer" style="color:${primary};"></i></span>
223
+ <span>ใช้นิ้วหรือเมาส์ขูดฟอยล์สีเงินเพื่อเปิดดูรางวัล</span>
224
+ </div>
25
225
  </div>
26
226
  `;
27
227
  }
228
+ function ensureFontAwesomeLoaded() {
229
+ if (typeof document === "undefined")
230
+ return;
231
+ const id = "pcampus-fa-cdn";
232
+ if (!document.getElementById(id)) {
233
+ const link = document.createElement("link");
234
+ link.id = id;
235
+ link.rel = "stylesheet";
236
+ link.href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css";
237
+ document.head.appendChild(link);
238
+ }
239
+ }
28
240
  export function mountScratchCard(target, rewards = [], theme, onAction) {
241
+ ensureFontAwesomeLoaded();
29
242
  target.innerHTML = renderScratchCardMarkup(rewards, theme);
30
243
  const canvas = target.querySelector(".pcampus-scratch-canvas");
31
- const hint = target.querySelector(".pcampus-scratch-overlay-hint");
244
+ const hint = target.querySelector(".pcampus-scratch-guide-hint");
32
245
  if (!canvas)
33
246
  return;
34
- const ctx = canvas.getContext("2d");
247
+ const ctx = canvas.getContext("2d", { willReadFrequently: true });
35
248
  if (!ctx)
36
249
  return;
37
- // Fill foil
38
- ctx.fillStyle = "#94a3b8";
250
+ // Initialize Canvas Resolution to match actual client display bounds
251
+ const rect = canvas.getBoundingClientRect();
252
+ if (rect.width > 0 && rect.height > 0) {
253
+ canvas.width = Math.round(rect.width * window.devicePixelRatio || rect.width);
254
+ canvas.height = Math.round(rect.height * window.devicePixelRatio || rect.height);
255
+ }
256
+ // Draw foil from Ticket color (hub), falling back to Foil color (primary)
257
+ const foilRgb = parseHexRgb(resolveScratchFoilTint(theme));
258
+ const grad = ctx.createLinearGradient(0, 0, canvas.width, canvas.height);
259
+ if (foilRgb) {
260
+ grad.addColorStop(0, scaleRgb(foilRgb, 0.72));
261
+ grad.addColorStop(0.35, scaleRgb(foilRgb, 1));
262
+ grad.addColorStop(0.7, scaleRgb(foilRgb, 0.78));
263
+ grad.addColorStop(1, scaleRgb(foilRgb, 0.55));
264
+ }
265
+ else {
266
+ grad.addColorStop(0, "#42423f");
267
+ grad.addColorStop(0.35, "#686862");
268
+ grad.addColorStop(0.7, "#42423f");
269
+ grad.addColorStop(1, "#333330");
270
+ }
271
+ ctx.fillStyle = grad;
39
272
  ctx.fillRect(0, 0, canvas.width, canvas.height);
273
+ // Subtle Pattern Grid
274
+ ctx.fillStyle = "rgba(255, 255, 255, 0.08)";
275
+ for (let x = 12; x < canvas.width; x += 22) {
276
+ for (let y = 12; y < canvas.height; y += 22) {
277
+ ctx.beginPath();
278
+ ctx.arc(x, y, 2, 0, Math.PI * 2);
279
+ ctx.fill();
280
+ }
281
+ }
40
282
  let isDrawing = false;
41
- let scratched = false;
42
- const scratch = (x, y) => {
283
+ let revealed = false;
284
+ let lastCheck = 0;
285
+ let lastX = 0;
286
+ let lastY = 0;
287
+ function checkPercent() {
288
+ if (revealed || !ctx || !canvas)
289
+ return;
290
+ const now = Date.now();
291
+ if (now - lastCheck < 160)
292
+ return;
293
+ lastCheck = now;
294
+ try {
295
+ const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
296
+ const data = imgData.data;
297
+ let clear = 0;
298
+ const total = data.length / 4;
299
+ const step = 8;
300
+ for (let i = 3; i < data.length; i += 4 * step) {
301
+ if (data[i] === 0)
302
+ clear += step;
303
+ }
304
+ if (clear / total >= 0.4) {
305
+ revealed = true;
306
+ canvas.style.transition = "opacity 400ms ease";
307
+ canvas.style.opacity = "0";
308
+ if (hint)
309
+ hint.style.opacity = "0";
310
+ setTimeout(() => {
311
+ canvas.remove();
312
+ if (rewards[0]) {
313
+ showCouponSuccess({
314
+ rewardName: rewards[0].name,
315
+ couponCode: rewards[0].couponCode,
316
+ });
317
+ }
318
+ if (onAction)
319
+ onAction(rewards[0]);
320
+ }, 420);
321
+ }
322
+ }
323
+ catch { }
324
+ }
325
+ const scratch = (x, y, isMove = false) => {
326
+ if (revealed || !ctx)
327
+ return;
328
+ // Set 100% solid opacity eraser (fixes 6% opacity bug)
43
329
  ctx.globalCompositeOperation = "destination-out";
330
+ ctx.fillStyle = "rgba(0, 0, 0, 1)";
331
+ ctx.strokeStyle = "rgba(0, 0, 0, 1)";
332
+ ctx.lineWidth = Math.max(34, canvas.width * 0.09);
333
+ ctx.lineCap = "round";
334
+ ctx.lineJoin = "round";
44
335
  ctx.beginPath();
45
- ctx.arc(x, y, 16, 0, Math.PI * 2);
46
- ctx.fill();
47
- if (hint && !scratched) {
48
- hint.style.opacity = "0";
49
- hint.style.transition = "opacity 200ms ease";
336
+ if (isMove) {
337
+ ctx.moveTo(lastX, lastY);
338
+ ctx.lineTo(x, y);
339
+ ctx.stroke();
50
340
  }
51
- if (!scratched) {
52
- scratched = true;
53
- if (onAction)
54
- onAction(rewards[0]);
341
+ else {
342
+ ctx.arc(x, y, Math.max(18, canvas.width * 0.045), 0, Math.PI * 2);
343
+ ctx.fill();
55
344
  }
345
+ lastX = x;
346
+ lastY = y;
347
+ if (hint && hint.style.opacity !== "0") {
348
+ hint.style.opacity = "0";
349
+ }
350
+ checkPercent();
56
351
  };
57
- const getPos = (e) => {
58
- const rect = canvas.getBoundingClientRect();
59
- const scaleX = canvas.width / rect.width;
60
- const scaleY = canvas.height / rect.height;
352
+ const getPos = (clientX, clientY) => {
353
+ const cRect = canvas.getBoundingClientRect();
354
+ const scaleX = canvas.width / cRect.width;
355
+ const scaleY = canvas.height / cRect.height;
61
356
  return {
62
- x: (e.clientX - rect.left) * scaleX,
63
- y: (e.clientY - rect.top) * scaleY,
357
+ x: (clientX - cRect.left) * scaleX,
358
+ y: (clientY - cRect.top) * scaleY,
64
359
  };
65
360
  };
66
361
  canvas.addEventListener("mousedown", (e) => {
67
362
  isDrawing = true;
68
- const pos = getPos(e);
69
- scratch(pos.x, pos.y);
363
+ const pos = getPos(e.clientX, e.clientY);
364
+ lastX = pos.x;
365
+ lastY = pos.y;
366
+ scratch(pos.x, pos.y, false);
70
367
  });
71
368
  window.addEventListener("mousemove", (e) => {
72
369
  if (!isDrawing)
73
370
  return;
74
- const pos = getPos(e);
75
- scratch(pos.x, pos.y);
371
+ const pos = getPos(e.clientX, e.clientY);
372
+ scratch(pos.x, pos.y, true);
76
373
  });
77
374
  window.addEventListener("mouseup", () => {
78
375
  isDrawing = false;
@@ -80,16 +377,19 @@ export function mountScratchCard(target, rewards = [], theme, onAction) {
80
377
  canvas.addEventListener("touchstart", (e) => {
81
378
  if (e.touches[0]) {
82
379
  isDrawing = true;
83
- const pos = getPos(e.touches[0]);
84
- scratch(pos.x, pos.y);
380
+ const pos = getPos(e.touches[0].clientX, e.touches[0].clientY);
381
+ lastX = pos.x;
382
+ lastY = pos.y;
383
+ scratch(pos.x, pos.y, false);
85
384
  }
86
- });
385
+ }, { passive: false });
87
386
  canvas.addEventListener("touchmove", (e) => {
88
387
  if (!isDrawing || !e.touches[0])
89
388
  return;
90
- const pos = getPos(e.touches[0]);
91
- scratch(pos.x, pos.y);
92
- });
389
+ e.preventDefault();
390
+ const pos = getPos(e.touches[0].clientX, e.touches[0].clientY);
391
+ scratch(pos.x, pos.y, true);
392
+ }, { passive: false });
93
393
  canvas.addEventListener("touchend", () => {
94
394
  isDrawing = false;
95
395
  });
@@ -4,7 +4,13 @@ export declare const MIN_WHEEL_SLICES = 6;
4
4
  export declare const WHEEL_CX = 160;
5
5
  export declare const WHEEL_CY = 160;
6
6
  export declare const WHEEL_R = 160;
7
- export declare const WHEEL_LABEL_RADIUS = 104;
7
+ export declare const PCAMPUS_SURFACE_CARD = "#1c1c1b";
8
+ export declare const PCAMPUS_SURFACE_SLATE = "#262624";
9
+ export declare const PCAMPUS_SURFACE_ONYX = "#181817";
10
+ export declare const PCAMPUS_ACCENT_BLUE = "#4b7fd4";
11
+ export declare const PCAMPUS_ACCENT_GOLD = "#e8a96a";
12
+ export declare const PCAMPUS_BORDER_SUBTLE = "rgba(240, 239, 237, 0.12)";
13
+ export declare const DEFAULT_PCAMPUS_SLICE_COLORS: readonly ["#1c1c1b", "#262624", "#181817"];
8
14
  export interface WheelSlice {
9
15
  id?: string;
10
16
  name: string;
@@ -18,5 +24,10 @@ export declare function polar(cx: number, cy: number, r: number, deg: number): [
18
24
  export declare function slicePath(cx: number, cy: number, r: number, startDeg: number, endDeg: number): string;
19
25
  export declare function visualSliceCounts(rewardCount: number): number[];
20
26
  export declare function wheelSlices(rewards: WidgetRewardItem[]): WheelSlice[];
27
+ export declare function sliceFaIcon(name: string): {
28
+ style: "regular" | "solid";
29
+ icon: string;
30
+ };
31
+ export declare function sliceLabelLines(name: string): string[];
21
32
  export declare function renderSpinWheelMarkup(rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme): string;
22
33
  export declare function mountSpinWheel(target: Element, rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme, onAction?: (reward?: WidgetRewardItem) => void): void;