@pcampus/reward-widget 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pcampus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # @pcampus/reward-widget
2
+
3
+ Public UI components and theme types for Pcampus Reward Widget integrations.
4
+
5
+ ## Relationship to the Reward Widget service
6
+
7
+ `Pcampus-reward-widget` is the private runtime and source of truth for campaign
8
+ configuration, eligibility, server draw, inventory, claims, webhooks, and the
9
+ hosted Player/Embed experience.
10
+
11
+ `Pcampus-reward-widget-library` is the public UI layer: reusable components,
12
+ theme tokens, and TypeScript types that consumer apps can render and style. It
13
+ does not contain API clients, draw or eligibility logic, campaign state,
14
+ secrets, claims, webhooks, or coupon data.
15
+
16
+ This package is UI-only. It never contains draw/eligibility clients, campaign
17
+ secrets, coupon codes, or authoritative server logic.
18
+
19
+ ## Status
20
+
21
+ Initial public scaffold. Component exports and registry publishing will be added
22
+ in the next implementation phase.
23
+
24
+ ## Public support matrix
25
+
26
+ The UI catalog targets `spin`, `scratch`, `pick-card`, `mystery-box`, `slot`,
27
+ and `quiz`. This package exports presentation primitives, theme configuration,
28
+ and public types only. Reward service APIs, server-side authority, claims,
29
+ webhooks, secrets, and coupon data remain outside the package.
30
+
31
+ ## Installation & Usage
32
+
33
+ ```bash
34
+ npm install @pcampus/reward-widget
35
+ ```
36
+
37
+ ### SSR / String Markup
38
+
39
+ ```typescript
40
+ import { renderRewardWidgetMarkup } from '@pcampus/reward-widget';
41
+
42
+ const html = renderRewardWidgetMarkup({
43
+ widgetType: 'spin',
44
+ state: 'eligible',
45
+ title: 'Daily Wheel of Fortune',
46
+ message: 'Spin now to win exclusive points!',
47
+ actionLabel: 'Spin Now',
48
+ theme: { brandPrimaryColor: '#2952a3' },
49
+ });
50
+ ```
51
+
52
+ ### Client DOM Mount
53
+
54
+ ```typescript
55
+ import { mountRewardWidget } from '@pcampus/reward-widget';
56
+
57
+ const container = document.getElementById('widget-root');
58
+ mountRewardWidget(container, {
59
+ widgetType: 'mystery-box',
60
+ state: 'eligible',
61
+ title: 'Special Mystery Box',
62
+ onAction: () => {
63
+ console.log('Action clicked');
64
+ },
65
+ });
66
+ ```
67
+
68
+ ## Theme Tokens & Customization
69
+
70
+ Consumers can customize widget styling using typed tokens and CSS custom properties:
71
+
72
+ ```typescript
73
+ import { createRewardWidgetTheme, rewardWidgetThemeCss } from '@pcampus/reward-widget';
74
+
75
+ // Create custom theme with fallbacks to defaults
76
+ const theme = createRewardWidgetTheme({
77
+ primary: '#2952a3',
78
+ background: '#0f172a',
79
+ radius: '0.5rem',
80
+ });
81
+
82
+ // Generate scoped CSS variables
83
+ const css = rewardWidgetThemeCss({ primary: '#2952a3' });
84
+ ```
85
+
86
+ ### Supported Tokens
87
+
88
+ | Token | Default | Purpose |
89
+ |---|---|---|
90
+ | `primary` | `#2952a3` | Primary brand color |
91
+ | `primaryHover` | `#1e3a8a` | Interactive hover state |
92
+ | `surface` | `#ffffff` | Component card/surface |
93
+ | `background` | `#0f172a` | Container background |
94
+ | `border` | `#cbd5e1` | Border and outline |
95
+ | `focus` | `#f59e0b` | Focus ring indicator |
96
+ | `disabled` | `#64748b` | Disabled control state |
97
+ | `warning` | `#f59e0b` | Warning message/badge |
98
+ | `blocked` | `#9f1239` | Ineligible / limit reached |
99
+ | `success` | `#047857` | Reward won / success |
100
+ | `radius` | `0.75rem` | Border radius |
101
+ | `spacing` | `1rem` | Component padding |
102
+ | `motion` | `160ms` | Transition duration |
103
+
104
+ ## Build
105
+
106
+ ```bash
107
+ npm install
108
+ npm run build
109
+ npm test
110
+ ```
111
+
112
+ The package build emits JavaScript and TypeScript declarations under `dist/`.
113
+
114
+ ## Public release policy
115
+
116
+ Releases use the public npm registry only after human review. Before publishing,
117
+ run `npm test`, `npm pack --dry-run`, and `npm audit --omit=dev`. The published
118
+ artifact is limited to `dist/`, this README, and the MIT license.
@@ -0,0 +1,15 @@
1
+ import type { RewardWidgetTheme, WidgetType } from "./index.js";
2
+ export type RewardWidgetState = "pending" | "eligible" | "blocked" | "error" | "complete";
3
+ export type RewardWidgetProps = {
4
+ widgetType: WidgetType;
5
+ state: RewardWidgetState;
6
+ title?: string;
7
+ message?: string;
8
+ actionLabel?: string;
9
+ theme?: RewardWidgetTheme;
10
+ onAction?: () => void;
11
+ };
12
+ /** Render a safe, UI-only Widget shell. It performs no draw or service work. */
13
+ export declare function renderRewardWidgetMarkup(props: RewardWidgetProps): string;
14
+ /** Mount the UI shell into a DOM element; event wiring remains consumer-owned. */
15
+ export declare function mountRewardWidget(target: Element, props: RewardWidgetProps): void;
@@ -0,0 +1,26 @@
1
+ const text = (value) => (value ?? "").replace(/[&<>"']/g, (character) => ({
2
+ "&": "&amp;",
3
+ "<": "&lt;",
4
+ ">": "&gt;",
5
+ '"': "&quot;",
6
+ "'": "&#39;",
7
+ })[character]);
8
+ /** Render a safe, UI-only Widget shell. It performs no draw or service work. */
9
+ export function renderRewardWidgetMarkup(props) {
10
+ const title = props.title ?? "Reward Widget";
11
+ const message = props.message ?? "";
12
+ const action = props.actionLabel ?? "Continue";
13
+ const primary = props.theme?.brandPrimaryColor ?? "#2952a3";
14
+ const background = props.theme?.pageBackgroundColor ?? "transparent";
15
+ const actionMarkup = props.onAction
16
+ ? `<button type="button" data-reward-widget-action>${text(action)}</button>`
17
+ : "";
18
+ return `<section class="pcampus-reward-widget" data-widget-type="${text(props.widgetType)}" data-state="${text(props.state)}" style="--pcampus-widget-primary:${text(primary)};--pcampus-widget-background:${text(background)}" aria-live="polite"><h2>${text(title)}</h2><p>${text(message)}</p>${actionMarkup}</section>`;
19
+ }
20
+ /** Mount the UI shell into a DOM element; event wiring remains consumer-owned. */
21
+ export function mountRewardWidget(target, props) {
22
+ target.innerHTML = renderRewardWidgetMarkup(props);
23
+ const action = target.querySelector("[data-reward-widget-action]");
24
+ if (action && props.onAction)
25
+ action.addEventListener("click", props.onAction);
26
+ }
@@ -0,0 +1,16 @@
1
+ export declare const WIDGET_TYPES: readonly ["spin", "scratch", "pick-card", "mystery-box", "quiz"];
2
+ export type WidgetType = (typeof WIDGET_TYPES)[number];
3
+ export type RewardWidgetTheme = {
4
+ brandName?: string;
5
+ brandLogoUrl?: string;
6
+ brandFont?: string;
7
+ brandPrimaryColor?: string;
8
+ pageBackgroundColor?: string;
9
+ wheelBorderColor?: string;
10
+ sliceColors?: readonly string[];
11
+ };
12
+ export { mountRewardWidget, renderRewardWidgetMarkup } from "./components.js";
13
+ export type { RewardWidgetProps, RewardWidgetState } from "./components.js";
14
+ export { createRewardWidgetTheme, defaultRewardWidgetTheme, rewardWidgetThemeCss, } from "./theme.js";
15
+ export type { RewardWidgetThemeTokens } from "./theme.js";
16
+ export * from "./widgets/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export const WIDGET_TYPES = [
2
+ "spin",
3
+ "scratch",
4
+ "pick-card",
5
+ "mystery-box",
6
+ "quiz",
7
+ ];
8
+ export { mountRewardWidget, renderRewardWidgetMarkup } from "./components.js";
9
+ export { createRewardWidgetTheme, defaultRewardWidgetTheme, rewardWidgetThemeCss, } from "./theme.js";
10
+ export * from "./widgets/index.js";
@@ -0,0 +1,18 @@
1
+ export type RewardWidgetThemeTokens = {
2
+ primary: string;
3
+ primaryHover: string;
4
+ surface: string;
5
+ background: string;
6
+ border: string;
7
+ focus: string;
8
+ disabled: string;
9
+ warning: string;
10
+ blocked: string;
11
+ success: string;
12
+ radius: string;
13
+ spacing: string;
14
+ motion: string;
15
+ };
16
+ export declare const defaultRewardWidgetTheme: RewardWidgetThemeTokens;
17
+ export declare function createRewardWidgetTheme(overrides?: Partial<RewardWidgetThemeTokens>): RewardWidgetThemeTokens;
18
+ export declare function rewardWidgetThemeCss(overrides?: Partial<RewardWidgetThemeTokens>): string;
package/dist/theme.js ADDED
@@ -0,0 +1,22 @@
1
+ export const defaultRewardWidgetTheme = {
2
+ primary: "#2952a3",
3
+ primaryHover: "#1e3a8a",
4
+ surface: "#ffffff",
5
+ background: "#0f172a",
6
+ border: "#cbd5e1",
7
+ focus: "#f59e0b",
8
+ disabled: "#64748b",
9
+ warning: "#f59e0b",
10
+ blocked: "#9f1239",
11
+ success: "#047857",
12
+ radius: "0.75rem",
13
+ spacing: "1rem",
14
+ motion: "160ms",
15
+ };
16
+ export function createRewardWidgetTheme(overrides = {}) {
17
+ return { ...defaultRewardWidgetTheme, ...overrides };
18
+ }
19
+ export function rewardWidgetThemeCss(overrides = {}) {
20
+ const theme = createRewardWidgetTheme(overrides);
21
+ return `:root{--pcampus-widget-primary:${theme.primary};--pcampus-widget-primary-hover:${theme.primaryHover};--pcampus-widget-surface:${theme.surface};--pcampus-widget-background:${theme.background};--pcampus-widget-border:${theme.border};--pcampus-widget-focus:${theme.focus};--pcampus-widget-disabled:${theme.disabled};--pcampus-widget-warning:${theme.warning};--pcampus-widget-blocked:${theme.blocked};--pcampus-widget-success:${theme.success};--pcampus-widget-radius:${theme.radius};--pcampus-widget-spacing:${theme.spacing};--pcampus-widget-motion:${theme.motion}}`;
22
+ }
@@ -0,0 +1,9 @@
1
+ export * from "./types.js";
2
+ export * from "./spin.js";
3
+ export * from "./scratch.js";
4
+ export * from "./pick-card.js";
5
+ export * from "./mystery-box.js";
6
+ export * from "./quiz.js";
7
+ import type { InteractiveWidgetOptions } from "./types.js";
8
+ export declare function renderInteractiveWidgetMarkup(options: InteractiveWidgetOptions): string;
9
+ export declare function mountInteractiveWidget(target: Element, options: InteractiveWidgetOptions): void;
@@ -0,0 +1,50 @@
1
+ export * from "./types.js";
2
+ export * from "./spin.js";
3
+ export * from "./scratch.js";
4
+ export * from "./pick-card.js";
5
+ export * from "./mystery-box.js";
6
+ export * from "./quiz.js";
7
+ import { renderSpinWheelMarkup, mountSpinWheel } from "./spin.js";
8
+ import { renderScratchCardMarkup, mountScratchCard } from "./scratch.js";
9
+ import { renderPickCardMarkup, mountPickCard } from "./pick-card.js";
10
+ import { renderMysteryBoxMarkup, mountMysteryBox } from "./mystery-box.js";
11
+ import { renderQuizMarkup, mountQuiz } from "./quiz.js";
12
+ export function renderInteractiveWidgetMarkup(options) {
13
+ const { widgetType, rewards = [], theme } = options;
14
+ switch (widgetType) {
15
+ case "spin":
16
+ return renderSpinWheelMarkup(rewards, theme);
17
+ case "scratch":
18
+ return renderScratchCardMarkup(rewards, theme);
19
+ case "pick-card":
20
+ return renderPickCardMarkup(rewards, theme);
21
+ case "mystery-box":
22
+ return renderMysteryBoxMarkup(rewards, theme);
23
+ case "quiz":
24
+ return renderQuizMarkup(rewards, theme);
25
+ default:
26
+ return `<div class="pcampus-unknown-widget">Unsupported widget type: ${widgetType}</div>`;
27
+ }
28
+ }
29
+ export function mountInteractiveWidget(target, options) {
30
+ const { widgetType, rewards = [], theme, onAction } = options;
31
+ switch (widgetType) {
32
+ case "spin":
33
+ mountSpinWheel(target, rewards, theme, onAction);
34
+ break;
35
+ case "scratch":
36
+ mountScratchCard(target, rewards, theme, onAction);
37
+ break;
38
+ case "pick-card":
39
+ mountPickCard(target, rewards, theme, onAction);
40
+ break;
41
+ case "mystery-box":
42
+ mountMysteryBox(target, rewards, theme, onAction);
43
+ break;
44
+ case "quiz":
45
+ mountQuiz(target, rewards, theme, onAction);
46
+ break;
47
+ default:
48
+ target.innerHTML = `<div class="pcampus-unknown-widget">Unsupported widget type: ${widgetType}</div>`;
49
+ }
50
+ }
@@ -0,0 +1,4 @@
1
+ import type { RewardWidgetTheme } from "../index.js";
2
+ import { type WidgetRewardItem } from "./types.js";
3
+ export declare function renderMysteryBoxMarkup(rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme): string;
4
+ export declare function mountMysteryBox(target: Element, rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme, onAction?: (reward?: WidgetRewardItem) => void): void;
@@ -0,0 +1,32 @@
1
+ export function renderMysteryBoxMarkup(rewards = [], theme) {
2
+ const primary = theme?.brandPrimaryColor || "#7c3aed";
3
+ return `
4
+ <div class="pcampus-mystery-box-wrap" style="position:relative;display:flex;flex-direction:column;align-items:center;justify-content:center;width:12rem;height:12rem;user-select:none;">
5
+ <div style="position:absolute;inset:1rem;border-radius:1.5rem;background:${primary};opacity:0.35;filter:blur(20px);"></div>
6
+
7
+ <div class="pcampus-mystery-box" style="position:relative;width:8rem;height:8rem;display:flex;flex-direction:column;align-items:center;justify-content:center;border-radius:1rem;border:3px solid rgba(252,211,77,0.8);background:#0f172a;box-shadow:0 20px 40px rgba(0,0,0,0.6);cursor:pointer;transition:transform 200ms ease;">
8
+ <div style="position:absolute;top:4px;left:4px;width:10px;height:10px;border-top:2px solid #fde68a;border-left:2px solid #fde68a;"></div>
9
+ <div style="position:absolute;top:4px;right:4px;width:10px;height:10px;border-top:2px solid #fde68a;border-right:2px solid #fde68a;"></div>
10
+ <div style="position:absolute;bottom:4px;left:4px;width:10px;height:10px;border-bottom:2px solid #fde68a;border-left:2px solid #fde68a;"></div>
11
+ <div style="position:absolute;bottom:4px;right:4px;width:10px;height:10px;border-bottom:2px solid #fde68a;border-right:2px solid #fde68a;"></div>
12
+
13
+ <span style="font-size:2.5rem;font-weight:900;color:#fcd34d;filter:drop-shadow(0 0 10px rgba(252,211,77,0.8));">?</span>
14
+ <span style="margin-top:4px;font-size:9px;font-weight:800;letter-spacing:0.1em;color:#fde68a;text-transform:uppercase;">MYSTERY CHEST</span>
15
+ </div>
16
+ </div>
17
+ `;
18
+ }
19
+ export function mountMysteryBox(target, rewards = [], theme, onAction) {
20
+ target.innerHTML = renderMysteryBoxMarkup(rewards, theme);
21
+ const box = target.querySelector(".pcampus-mystery-box");
22
+ if (box) {
23
+ box.addEventListener("click", () => {
24
+ box.style.transform = "scale(1.1) rotate(-3deg)";
25
+ setTimeout(() => {
26
+ box.style.transform = "scale(1)";
27
+ if (onAction)
28
+ onAction(rewards[0]);
29
+ }, 300);
30
+ });
31
+ }
32
+ }
@@ -0,0 +1,4 @@
1
+ import type { RewardWidgetTheme } from "../index.js";
2
+ import { type WidgetRewardItem } from "./types.js";
3
+ export declare function renderPickCardMarkup(rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme): string;
4
+ export declare function mountPickCard(target: Element, rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme, onAction?: (reward?: WidgetRewardItem) => void): void;
@@ -0,0 +1,51 @@
1
+ export function renderPickCardMarkup(rewards = [], theme) {
2
+ const primary = theme?.brandPrimaryColor || "#1e40af";
3
+ const items = rewards.length >= 3
4
+ ? rewards.slice(0, 3)
5
+ : [
6
+ rewards[0] || { name: "1st Prize" },
7
+ rewards[1] || { name: "2nd Prize" },
8
+ rewards[2] || { name: "3rd Prize" },
9
+ ];
10
+ const cardsHtml = items
11
+ .map((item, idx) => `
12
+ <div class="pcampus-pick-card" data-card-index="${idx}" style="position:relative;width:5rem;height:8.5rem;display:flex;flex-direction:column;align-items:center;justify-content:space-between;padding:0.5rem;border-radius:0.75rem;border:2px solid rgba(252,211,77,0.6);background:${primary};box-shadow:0 12px 24px rgba(0,0,0,0.4);cursor:pointer;user-select:none;transition:transform 200ms ease, box-shadow 200ms ease;">
13
+ <div style="display:flex;width:100%;justify-content:space-between;color:#fde68a;">
14
+ <svg style="width:8px;height:8px;fill:currentColor;" viewBox="0 0 24 24"><path d="M12 2l2.5 7.5L22 12l-7.5 2.5L12 22l-2.5-7.5L2 12l7.5-2.5z"/></svg>
15
+ <svg style="width:8px;height:8px;fill:currentColor;" viewBox="0 0 24 24"><path d="M12 2l2.5 7.5L22 12l-7.5 2.5L12 22l-2.5-7.5L2 12l7.5-2.5z"/></svg>
16
+ </div>
17
+
18
+ <div class="pcampus-card-center" style="display:flex;flex-direction:column;align-items:center;gap:2px;">
19
+ <svg style="width:24px;height:24px;color:#fde68a;" fill="none" viewBox="0 0 24 24" stroke="currentColor">
20
+ <rect x="5" y="3" width="14" height="18" rx="2" stroke-width="1.5" fill="rgba(255,255,255,0.15)"/>
21
+ <path d="M12 8l1.5 2.5L16 12l-2.5 1.5L12 16l-1.5-2.5L8 12l2.5-1.5z" fill="currentColor"/>
22
+ </svg>
23
+ <span style="font-size:9px;font-weight:900;letter-spacing:0.05em;color:#ffffff;text-transform:uppercase;">PICK</span>
24
+ </div>
25
+
26
+ <div style="display:flex;width:100%;justify-content:space-between;color:#fde68a;">
27
+ <svg style="width:8px;height:8px;fill:currentColor;" viewBox="0 0 24 24"><path d="M12 2l2.5 7.5L22 12l-7.5 2.5L12 22l-2.5-7.5L2 12l7.5-2.5z"/></svg>
28
+ <svg style="width:8px;height:8px;fill:currentColor;" viewBox="0 0 24 24"><path d="M12 2l2.5 7.5L22 12l-7.5 2.5L12 22l-2.5-7.5L2 12l7.5-2.5z"/></svg>
29
+ </div>
30
+ </div>
31
+ `)
32
+ .join("");
33
+ return `
34
+ <div class="pcampus-pick-card-wrap" style="display:flex;align-items:center;justify-content:center;gap:0.6rem;padding:0.5rem;">
35
+ ${cardsHtml}
36
+ </div>
37
+ `;
38
+ }
39
+ export function mountPickCard(target, rewards = [], theme, onAction) {
40
+ target.innerHTML = renderPickCardMarkup(rewards, theme);
41
+ const cards = target.querySelectorAll(".pcampus-pick-card");
42
+ cards.forEach((card, idx) => {
43
+ card.addEventListener("click", () => {
44
+ card.style.transform = "translateY(-10px) scale(1.05)";
45
+ card.style.borderColor = "#fcd34d";
46
+ const chosen = rewards[idx] || rewards[0];
47
+ if (onAction)
48
+ onAction(chosen);
49
+ });
50
+ });
51
+ }
@@ -0,0 +1,4 @@
1
+ import type { RewardWidgetTheme } from "../index.js";
2
+ import { type WidgetRewardItem } from "./types.js";
3
+ export declare function renderQuizMarkup(rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme): string;
4
+ export declare function mountQuiz(target: Element, rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme, onAction?: (reward?: WidgetRewardItem) => void): void;
@@ -0,0 +1,61 @@
1
+ export function renderQuizMarkup(rewards = [], theme) {
2
+ const primary = theme?.brandPrimaryColor || "#0284c7";
3
+ return `
4
+ <div class="pcampus-quiz-widget-wrap" style="position:relative;display:flex;flex-direction:column;align-items:center;width:100%;max-width:270px;padding:1rem;border-radius:1rem;border:1px solid rgba(255,255,255,0.15);background:#ffffff;color:#0f172a;box-shadow:0 20px 40px rgba(0,0,0,0.5);">
5
+ <div style="display:inline-flex;align-items:center;gap:6px;padding:2px 8px;border-radius:9999px;background:#f1f5f9;font-size:10px;font-weight:700;color:#475569;margin-bottom:0.6rem;">
6
+ <svg style="width:12px;height:12px;color:#64748b;" fill="none" viewBox="0 0 24 24" stroke="currentColor">
7
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
8
+ </svg>
9
+ <span>แบบสอบถามเพื่อรับรางวัล</span>
10
+ </div>
11
+
12
+ <div style="width:100%;display:flex;justify-content:space-between;font-size:10px;font-weight:700;color:#64748b;margin-bottom:4px;">
13
+ <span>คำถามที่ 1</span>
14
+ <span style="color:${primary};">1 / 3</span>
15
+ </div>
16
+ <div style="width:100%;height:6px;border-radius:9999px;background:#f1f5f9;border:1px solid #e2e8f0;overflow:hidden;margin-bottom:0.75rem;">
17
+ <div style="width:33%;height:100%;background:${primary};border-radius:9999px;"></div>
18
+ </div>
19
+
20
+ <p style="width:100%;text-align:left;font-size:12px;font-weight:800;color:#0f172a;margin:0 0 0.6rem 0;">
21
+ คุณพึงพอใจกับบริการในระดับใด?
22
+ </p>
23
+
24
+ <div style="width:100%;display:flex;flex-direction:column;gap:6px;">
25
+ <button type="button" class="pcampus-quiz-opt" style="width:100%;display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-radius:0.75rem;background:${primary};color:#ffffff;border:none;font-size:11px;font-weight:600;cursor:pointer;">
26
+ <div style="display:flex;align-items:center;gap:6px;">
27
+ <span style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:4px;background:rgba(255,255,255,0.2);font-size:10px;font-weight:700;">A</span>
28
+ <span>พึงพอใจมากที่สุด</span>
29
+ </div>
30
+ <svg style="width:14px;height:14px;" fill="none" viewBox="0 0 24 24" stroke="currentColor">
31
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="3" d="M5 13l4 4L19 7"/>
32
+ </svg>
33
+ </button>
34
+
35
+ <button type="button" class="pcampus-quiz-opt" style="width:100%;display:flex;align-items:center;justify-content:space-between;padding:6px 10px;border-radius:0.75rem;background:#f8fafc;color:#334155;border:1px solid #e2e8f0;font-size:11px;font-weight:500;cursor:pointer;">
36
+ <div style="display:flex;align-items:center;gap:6px;">
37
+ <span style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;border-radius:4px;background:#ffffff;border:1px solid #cbd5e1;font-size:10px;font-weight:700;color:#64748b;">B</span>
38
+ <span>ปานกลาง</span>
39
+ </div>
40
+ </button>
41
+ </div>
42
+
43
+ <div style="margin-top:0.75rem;display:flex;align-items:center;justify-content:center;gap:6px;border-top:1px dashed #e2e8f0;padding-top:6px;font-size:10px;font-weight:600;color:#d97706;">
44
+ <svg style="width:12px;height:12px;" fill="none" viewBox="0 0 24 24" stroke="currentColor">
45
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v13m0-13V6a2 2 0 112 2h-2zm0 0V6a2 2 0 10-2 2h2zm-7 5h14v7a2 2 0 01-2 2H7a2 2 0 01-2-2v-7zM3 10h18v3H3v-3z"/>
46
+ </svg>
47
+ <span>ตอบครบ 3 ข้อรับรางวัลทันที</span>
48
+ </div>
49
+ </div>
50
+ `;
51
+ }
52
+ export function mountQuiz(target, rewards = [], theme, onAction) {
53
+ target.innerHTML = renderQuizMarkup(rewards, theme);
54
+ const opts = target.querySelectorAll(".pcampus-quiz-opt");
55
+ opts.forEach((btn) => {
56
+ btn.addEventListener("click", () => {
57
+ if (onAction)
58
+ onAction(rewards[0]);
59
+ });
60
+ });
61
+ }
@@ -0,0 +1,4 @@
1
+ import type { RewardWidgetTheme } from "../index.js";
2
+ import { type WidgetRewardItem } from "./types.js";
3
+ export declare function renderScratchCardMarkup(rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme): string;
4
+ export declare function mountScratchCard(target: Element, rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme, onAction?: (reward?: WidgetRewardItem) => void): void;
@@ -0,0 +1,96 @@
1
+ import { textEscape } from "./types.js";
2
+ export function renderScratchCardMarkup(rewards = [], theme) {
3
+ const topReward = rewards[0]?.name || "Lucky Special Reward";
4
+ const primary = theme?.brandPrimaryColor || "#2563eb";
5
+ 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>
11
+ </div>
12
+
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>
14
+
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>
22
+ </div>
23
+ </div>
24
+ <span style="margin-top:0.6rem;font-size:11px;font-weight:500;color:#94a3b8;">Scratch to reveal your lucky prize!</span>
25
+ </div>
26
+ `;
27
+ }
28
+ export function mountScratchCard(target, rewards = [], theme, onAction) {
29
+ target.innerHTML = renderScratchCardMarkup(rewards, theme);
30
+ const canvas = target.querySelector(".pcampus-scratch-canvas");
31
+ const hint = target.querySelector(".pcampus-scratch-overlay-hint");
32
+ if (!canvas)
33
+ return;
34
+ const ctx = canvas.getContext("2d");
35
+ if (!ctx)
36
+ return;
37
+ // Fill foil
38
+ ctx.fillStyle = "#94a3b8";
39
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
40
+ let isDrawing = false;
41
+ let scratched = false;
42
+ const scratch = (x, y) => {
43
+ ctx.globalCompositeOperation = "destination-out";
44
+ 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";
50
+ }
51
+ if (!scratched) {
52
+ scratched = true;
53
+ if (onAction)
54
+ onAction(rewards[0]);
55
+ }
56
+ };
57
+ const getPos = (e) => {
58
+ const rect = canvas.getBoundingClientRect();
59
+ const scaleX = canvas.width / rect.width;
60
+ const scaleY = canvas.height / rect.height;
61
+ return {
62
+ x: (e.clientX - rect.left) * scaleX,
63
+ y: (e.clientY - rect.top) * scaleY,
64
+ };
65
+ };
66
+ canvas.addEventListener("mousedown", (e) => {
67
+ isDrawing = true;
68
+ const pos = getPos(e);
69
+ scratch(pos.x, pos.y);
70
+ });
71
+ window.addEventListener("mousemove", (e) => {
72
+ if (!isDrawing)
73
+ return;
74
+ const pos = getPos(e);
75
+ scratch(pos.x, pos.y);
76
+ });
77
+ window.addEventListener("mouseup", () => {
78
+ isDrawing = false;
79
+ });
80
+ canvas.addEventListener("touchstart", (e) => {
81
+ if (e.touches[0]) {
82
+ isDrawing = true;
83
+ const pos = getPos(e.touches[0]);
84
+ scratch(pos.x, pos.y);
85
+ }
86
+ });
87
+ canvas.addEventListener("touchmove", (e) => {
88
+ if (!isDrawing || !e.touches[0])
89
+ return;
90
+ const pos = getPos(e.touches[0]);
91
+ scratch(pos.x, pos.y);
92
+ });
93
+ canvas.addEventListener("touchend", () => {
94
+ isDrawing = false;
95
+ });
96
+ }
@@ -0,0 +1,22 @@
1
+ import type { RewardWidgetTheme } from "../index.js";
2
+ import { type WidgetRewardItem } from "./types.js";
3
+ export declare const MIN_WHEEL_SLICES = 6;
4
+ export declare const WHEEL_CX = 160;
5
+ export declare const WHEEL_CY = 160;
6
+ export declare const WHEEL_R = 160;
7
+ export declare const WHEEL_LABEL_RADIUS = 104;
8
+ export interface WheelSlice {
9
+ id?: string;
10
+ name: string;
11
+ weight?: number;
12
+ fillIndex: number;
13
+ startDeg: number;
14
+ endDeg: number;
15
+ midDeg: number;
16
+ }
17
+ export declare function polar(cx: number, cy: number, r: number, deg: number): [number, number];
18
+ export declare function slicePath(cx: number, cy: number, r: number, startDeg: number, endDeg: number): string;
19
+ export declare function visualSliceCounts(rewardCount: number): number[];
20
+ export declare function wheelSlices(rewards: WidgetRewardItem[]): WheelSlice[];
21
+ export declare function renderSpinWheelMarkup(rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme): string;
22
+ export declare function mountSpinWheel(target: Element, rewards?: WidgetRewardItem[], theme?: RewardWidgetTheme, onAction?: (reward?: WidgetRewardItem) => void): void;
@@ -0,0 +1,171 @@
1
+ import { textEscape } from "./types.js";
2
+ export const MIN_WHEEL_SLICES = 6;
3
+ export const WHEEL_CX = 160;
4
+ export const WHEEL_CY = 160;
5
+ export const WHEEL_R = 160;
6
+ export const WHEEL_LABEL_RADIUS = 104;
7
+ export function polar(cx, cy, r, deg) {
8
+ const rad = ((deg - 90) * Math.PI) / 180;
9
+ return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
10
+ }
11
+ export function slicePath(cx, cy, r, startDeg, endDeg) {
12
+ const sweep = endDeg - startDeg;
13
+ if (sweep <= 0 || sweep >= 359.999)
14
+ return "";
15
+ const [x1, y1] = polar(cx, cy, r, startDeg);
16
+ const [x2, y2] = polar(cx, cy, r, endDeg);
17
+ const large = sweep > 180 ? 1 : 0;
18
+ return `M ${cx} ${cy} L ${x1.toFixed(3)} ${y1.toFixed(3)} A ${r} ${r} 0 ${large} 1 ${x2.toFixed(3)} ${y2.toFixed(3)} Z`;
19
+ }
20
+ export function visualSliceCounts(rewardCount) {
21
+ if (rewardCount <= 0)
22
+ return [];
23
+ if (rewardCount >= MIN_WHEEL_SLICES) {
24
+ return Array.from({ length: rewardCount }, () => 1);
25
+ }
26
+ const base = Math.floor(MIN_WHEEL_SLICES / rewardCount);
27
+ const counts = Array.from({ length: rewardCount }, () => base);
28
+ let extras = MIN_WHEEL_SLICES - base * rewardCount;
29
+ for (let i = 0; extras > 0; i++) {
30
+ counts[i] += 1;
31
+ extras -= 1;
32
+ }
33
+ return counts;
34
+ }
35
+ export function wheelSlices(rewards) {
36
+ const valid = rewards.filter((r) => (r.name ?? "").trim().length > 0);
37
+ const list = valid.length > 0
38
+ ? valid
39
+ : [
40
+ { name: "Special Reward" },
41
+ { name: "Lucky Bonus" },
42
+ { name: "100 Points" },
43
+ { name: "50% Discount" },
44
+ ];
45
+ const counts = visualSliceCounts(list.length);
46
+ const remaining = [...counts];
47
+ const total = counts.reduce((sum, n) => sum + n, 0);
48
+ const expanded = [];
49
+ while (expanded.length < total) {
50
+ for (let i = 0; i < list.length; i++) {
51
+ if (remaining[i] > 0) {
52
+ expanded.push({ item: list[i], fillIndex: i });
53
+ remaining[i]--;
54
+ }
55
+ }
56
+ }
57
+ const sweep = 360 / expanded.length;
58
+ return expanded.map((entry, i) => ({
59
+ id: entry.item.id,
60
+ name: entry.item.name,
61
+ weight: entry.item.weight,
62
+ fillIndex: entry.fillIndex,
63
+ startDeg: i * sweep,
64
+ endDeg: (i + 1) * sweep,
65
+ midDeg: (i + 0.5) * sweep,
66
+ }));
67
+ }
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"/>`);
77
+ }
78
+ return parts.join("");
79
+ }
80
+ export function renderSpinWheelMarkup(rewards = [], theme) {
81
+ const slices = wheelSlices(rewards);
82
+ const colors = theme?.sliceColors && theme.sliceColors.length === 3
83
+ ? theme.sliceColors
84
+ : ["#ffffff", "#dbeafe", "#1e3a8a"];
85
+ const borderColor = theme?.wheelBorderColor || "#233044";
86
+ const slicesSvg = slices
87
+ .map((slice, i) => {
88
+ const color = colors[i % colors.length];
89
+ const isDark = color === "#1e3a8a" || color === "#1e293b" || color === "#0f172a";
90
+ const textColor = isDark ? "#ffffff" : "#0f172a";
91
+ 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;
93
+ return `
94
+ <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>
100
+ </g>
101
+ </g>
102
+ `;
103
+ })
104
+ .join("");
105
+ 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;">
109
+ <svg viewBox="0 0 320 320" style="width:100%;height:100%;display:block;">
110
+ ${slicesSvg}
111
+ ${pegsMarkup()}
112
+ </svg>
113
+ </div>
114
+
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">
117
+ <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"/>
122
+ </linearGradient>
123
+ </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)"/>
125
+ </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>
130
+ </button>
131
+ </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);">
133
+ พร้อมหมุนสุ่มรางวัล!
134
+ </div>
135
+ </div>
136
+ `;
137
+ }
138
+ export function mountSpinWheel(target, rewards = [], theme, onAction) {
139
+ target.innerHTML = renderSpinWheelMarkup(rewards, theme);
140
+ const disc = target.querySelector(".pcampus-spin-wheel-disc");
141
+ const btn = target.querySelector(".pcampus-spin-hub-btn");
142
+ const outcome = target.querySelector(".pcampus-spin-outcome");
143
+ let isSpinning = false;
144
+ let rotation = 0;
145
+ if (btn && disc) {
146
+ btn.addEventListener("click", () => {
147
+ if (isSpinning)
148
+ return;
149
+ isSpinning = true;
150
+ btn.disabled = true;
151
+ if (outcome)
152
+ 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)`;
157
+ setTimeout(() => {
158
+ isSpinning = false;
159
+ 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>`;
164
+ }
165
+ if (onAction) {
166
+ onAction(winningSlice ? { name: winningSlice.name, id: winningSlice.id } : undefined);
167
+ }
168
+ }, 4600);
169
+ });
170
+ }
171
+ }
@@ -0,0 +1,18 @@
1
+ import type { RewardWidgetTheme, WidgetType } from "../index.js";
2
+ export interface WidgetRewardItem {
3
+ id?: string;
4
+ name: string;
5
+ weight?: number;
6
+ icon?: string;
7
+ description?: string;
8
+ couponCode?: string;
9
+ }
10
+ export interface InteractiveWidgetOptions {
11
+ widgetType: WidgetType;
12
+ rewards?: WidgetRewardItem[];
13
+ theme?: RewardWidgetTheme;
14
+ title?: string;
15
+ disabled?: boolean;
16
+ onAction?: (reward?: WidgetRewardItem) => void;
17
+ }
18
+ export declare const textEscape: (value: string | undefined) => string;
@@ -0,0 +1,7 @@
1
+ export const textEscape = (value) => (value ?? "").replace(/[&<>"']/g, (character) => ({
2
+ "&": "&amp;",
3
+ "<": "&lt;",
4
+ ">": "&gt;",
5
+ '"': "&quot;",
6
+ "'": "&#39;",
7
+ })[character]);
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@pcampus/reward-widget",
3
+ "version": "0.1.0",
4
+ "description": "Public UI components and theme types for Pcampus Reward Widget integrations.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": ["dist", "README.md", "LICENSE"],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "scripts": {
21
+ "build": "tsc -p tsconfig.json",
22
+ "test": "npm run build && node --test",
23
+ "pack:dry-run": "npm pack --dry-run"
24
+ },
25
+ "keywords": ["pcampus", "reward", "widget", "embed"],
26
+ "devDependencies": {
27
+ "typescript": "^5.7.0"
28
+ }
29
+ }