@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 +21 -0
- package/README.md +118 -0
- package/dist/components.d.ts +15 -0
- package/dist/components.js +26 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +10 -0
- package/dist/theme.d.ts +18 -0
- package/dist/theme.js +22 -0
- package/dist/widgets/index.d.ts +9 -0
- package/dist/widgets/index.js +50 -0
- package/dist/widgets/mystery-box.d.ts +4 -0
- package/dist/widgets/mystery-box.js +32 -0
- package/dist/widgets/pick-card.d.ts +4 -0
- package/dist/widgets/pick-card.js +51 -0
- package/dist/widgets/quiz.d.ts +4 -0
- package/dist/widgets/quiz.js +61 -0
- package/dist/widgets/scratch.d.ts +4 -0
- package/dist/widgets/scratch.js +96 -0
- package/dist/widgets/spin.d.ts +22 -0
- package/dist/widgets/spin.js +171 -0
- package/dist/widgets/types.d.ts +18 -0
- package/dist/widgets/types.js +7 -0
- package/package.json +29 -0
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
|
+
"&": "&",
|
|
3
|
+
"<": "<",
|
|
4
|
+
">": ">",
|
|
5
|
+
'"': """,
|
|
6
|
+
"'": "'",
|
|
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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|
package/dist/theme.d.ts
ADDED
|
@@ -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;
|
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
|
+
}
|