@useaward/embed-predictions 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/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@useaward/embed-predictions",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "files": [
6
+ "./dist",
7
+ "./src"
8
+ ],
9
+ "exports": {
10
+ "./web": "./dist/web.js"
11
+ },
12
+ "author": "Useaward",
13
+ "license": "ISC",
14
+ "scripts": {
15
+ "dev": "tsup --watch",
16
+ "build": "tsup",
17
+ "clean": "git clean -xdf .cache .turbo dist node_modules",
18
+ "format": "prettier --check . --ignore-path ../../.gitignore",
19
+ "lint": "eslint",
20
+ "typecheck": "tsc --noEmit --emitDeclarationOnly false"
21
+ },
22
+ "devDependencies": {
23
+ "@tailwindcss/postcss": "catalog:",
24
+ "@useaward/eslint-config": "workspace:*",
25
+ "@useaward/prettier-config": "workspace:*",
26
+ "@useaward/tsconfig": "workspace:*",
27
+ "esbuild-plugin-solid": "^0.6.0",
28
+ "eslint": "catalog:",
29
+ "postcss": "^8.5.6",
30
+ "prettier": "catalog:",
31
+ "tailwindcss": "catalog:",
32
+ "tsup": "^8.5.1",
33
+ "typescript": "catalog:"
34
+ },
35
+ "prettier": "@useaward/prettier-config",
36
+ "dependencies": {
37
+ "@ark-ui/solid": "^5.30.0",
38
+ "@kobalte/core": "^0.13.11",
39
+ "@solid-primitives/destructure": "^0.2.2",
40
+ "@solid-primitives/i18n": "^2.2.1",
41
+ "@solid-primitives/utils": "^6.3.2",
42
+ "clsx": "^2.1.1",
43
+ "date-fns": "catalog:",
44
+ "lucide-solid": "^0.563.0",
45
+ "solid-element": "^1.9.1",
46
+ "solid-js": "^1.9.11",
47
+ "tailwind-merge": "^3.4.0"
48
+ }
49
+ }
@@ -0,0 +1,107 @@
1
+ export type StepValue = {
2
+ id: string;
3
+ order: number;
4
+ value: {
5
+ id: string;
6
+ name: string;
7
+ shortName: string | null;
8
+ image: {
9
+ publicUrl: string;
10
+ } | null;
11
+ };
12
+ };
13
+
14
+ export type Step = {
15
+ id: string;
16
+ type: "scoreGame" | "correctValue";
17
+ title: string;
18
+ dateTime: Date | null;
19
+ order: number;
20
+ leagueIndicator: string | null;
21
+ stepValues: Array<StepValue>;
22
+ };
23
+
24
+ export type GameData = {
25
+ id: string;
26
+ name: string;
27
+ description: string | null;
28
+ image: {
29
+ publicUrl: string;
30
+ } | null;
31
+ settings: {
32
+ openAt?: Date;
33
+ closesAt?: Date;
34
+ requiresEmail: boolean;
35
+ allowEmailFromParams: boolean;
36
+ termsUrl?: string;
37
+ endDescription?: string;
38
+ fontFamily?: string;
39
+ questionTextColor?: string;
40
+ buttonBackground?: string;
41
+ buttonTextColor?: string;
42
+ backgroundColor?: string;
43
+ selectionBackground?: string;
44
+ selectionTextColor?: string;
45
+ };
46
+ steps: Step[];
47
+ };
48
+
49
+ export type SubmissionValue = {
50
+ stepValueId: string;
51
+ predictedScore?: number;
52
+ isSelected?: boolean;
53
+ };
54
+
55
+ export type Submission = {
56
+ stepId: string;
57
+ values: SubmissionValue[];
58
+ };
59
+
60
+ export type SubmissionResult = {
61
+ success: boolean;
62
+ message?: string;
63
+ };
64
+
65
+ export class EmbedAPIClient {
66
+ constructor(private baseUrl: string) {}
67
+
68
+ async getGame(predictionGameId: string): Promise<GameData> {
69
+ const response = await fetch(
70
+ `${this.baseUrl}/api/embed/prediction-game/${predictionGameId}`,
71
+ );
72
+
73
+ if (!response.ok) {
74
+ const error = await response.json().catch(() => ({}));
75
+ throw new Error(error.error || "Failed to fetch game");
76
+ }
77
+
78
+ return response.json();
79
+ }
80
+
81
+ async submitPrediction(
82
+ predictionGameId: string,
83
+ email: string,
84
+ submissions: Submission[],
85
+ ): Promise<SubmissionResult> {
86
+ const response = await fetch(
87
+ `${this.baseUrl}/api/embed/prediction-game/${predictionGameId}/submit`,
88
+ {
89
+ method: "POST",
90
+ headers: {
91
+ "Content-Type": "application/json",
92
+ },
93
+ body: JSON.stringify({
94
+ email,
95
+ submissions,
96
+ }),
97
+ },
98
+ );
99
+
100
+ if (!response.ok) {
101
+ const error = await response.json().catch(() => ({}));
102
+ throw new Error(error.error || "Failed to submit prediction");
103
+ }
104
+
105
+ return response.json();
106
+ }
107
+ }
@@ -0,0 +1,15 @@
1
+ @import "tailwindcss";
2
+
3
+ :host {
4
+ }
5
+
6
+ @theme inline {
7
+ }
8
+
9
+ /* Hide scrollbar for Chrome, Safari and Opera */
10
+ .scrollable-container::-webkit-scrollbar {
11
+ display: none;
12
+ }
13
+
14
+ @layer base {
15
+ }
@@ -0,0 +1,52 @@
1
+ import { useLocaleContext } from "@/context/locale-context";
2
+ import { Show } from "solid-js";
3
+
4
+ import { useGameContext } from "../context/GameContext";
5
+ import { Button } from "./ui/button";
6
+
7
+ export const ButtonBar = () => {
8
+ const { gameData, store, totalSteps, handleNext, handleSubmit } =
9
+ useGameContext();
10
+ const { t } = useLocaleContext();
11
+ const isLastStep = () => store.state.currentStepIndex === totalSteps() - 1;
12
+
13
+ const onButtonClick = () => {
14
+ if (isLastStep()) {
15
+ handleSubmit();
16
+ } else {
17
+ handleNext();
18
+ }
19
+ };
20
+
21
+ return (
22
+ <div class="rounded-lg">
23
+ <div class="flex gap-3">
24
+ <Button
25
+ fullWidth
26
+ onClick={onButtonClick}
27
+ loading={store.state.submitting}
28
+ >
29
+ <Show
30
+ when={isLastStep()}
31
+ fallback={t("predictionGame.confirmResult")}
32
+ >
33
+ {t("predictionGame.submit")}
34
+ </Show>
35
+ </Button>
36
+ </div>
37
+
38
+ <Show when={gameData.settings.termsUrl}>
39
+ <div class="mt-3 text-center">
40
+ <a
41
+ href={gameData.settings.termsUrl}
42
+ target="_blank"
43
+ rel="noopener noreferrer"
44
+ class="text-sm text-gray-600 underline hover:text-gray-800"
45
+ >
46
+ Terms and Conditions
47
+ </a>
48
+ </div>
49
+ </Show>
50
+ </div>
51
+ );
52
+ };
@@ -0,0 +1,80 @@
1
+ import { Show } from "solid-js";
2
+
3
+ type StepValue = {
4
+ id: string;
5
+ order: number;
6
+ value: {
7
+ id: string;
8
+ name: string;
9
+ shortName: string | null;
10
+ image: {
11
+ publicUrl: string;
12
+ } | null;
13
+ };
14
+ };
15
+
16
+ type Step = {
17
+ id: string;
18
+ type: "scoreGame" | "correctValue";
19
+ title: string;
20
+ dateTime: Date | null;
21
+ order: number;
22
+ leagueIndicator: string | null;
23
+ stepValues: StepValue[];
24
+ };
25
+
26
+ type Prediction = {
27
+ selectedStepValueId?: string;
28
+ };
29
+
30
+ type CorrectValueStepProps = {
31
+ step: Step;
32
+ prediction: Prediction;
33
+ onPredictionChange: (stepValueId: string) => void;
34
+ questionTextColor?: string;
35
+ selectionBackgroundColor?: string;
36
+ selectionTextColor?: string;
37
+ };
38
+
39
+ export const CorrectValueStep = (props: CorrectValueStepProps) => {
40
+ const sortedValues = () =>
41
+ [...props.step.stepValues].sort((a, b) => a.order - b.order);
42
+
43
+ return (
44
+ <>
45
+ <div class="space-y-3">
46
+ {sortedValues().map((stepValue) => {
47
+ const isSelected = () =>
48
+ props.prediction.selectedStepValueId === stepValue.id;
49
+
50
+ return (
51
+ <button
52
+ type="button"
53
+ onClick={() => props.onPredictionChange(stepValue.id)}
54
+ class="flex w-full items-center justify-start rounded-lg border-2 px-4 py-3 text-left transition-all"
55
+ style={{
56
+ "background-color": isSelected()
57
+ ? props.selectionBackgroundColor || "#f97316"
58
+ : "#ffffff",
59
+ "border-color": isSelected()
60
+ ? props.selectionBackgroundColor || "#f97316"
61
+ : "#e5e7eb",
62
+ }}
63
+ >
64
+ <span
65
+ class="font-medium"
66
+ style={{
67
+ color: isSelected()
68
+ ? props.selectionTextColor || "#000000"
69
+ : props.questionTextColor || "#111827",
70
+ }}
71
+ >
72
+ {stepValue.value.name}
73
+ </span>
74
+ </button>
75
+ );
76
+ })}
77
+ </div>
78
+ </>
79
+ );
80
+ };
@@ -0,0 +1,38 @@
1
+ import { Show } from "solid-js";
2
+
3
+ type EmailInputProps = {
4
+ value: string;
5
+ onChange: (value: string) => void;
6
+ error?: string;
7
+ disabled?: boolean;
8
+ };
9
+
10
+ export const EmailInput = (props: EmailInputProps) => {
11
+ const handleInput = (e: InputEvent) => {
12
+ const target = e.currentTarget as HTMLInputElement;
13
+ props.onChange(target.value);
14
+ };
15
+
16
+ return (
17
+ <>
18
+ <label for="email" class="mb-1 block text-sm font-medium">
19
+ Email Address
20
+ </label>
21
+ <input
22
+ id="email"
23
+ type="email"
24
+ value={props.value}
25
+ onInput={handleInput}
26
+ disabled={props.disabled}
27
+ placeholder="Enter your email"
28
+ class="w-full rounded border border-gray-300 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:outline-none disabled:cursor-not-allowed disabled:bg-gray-100"
29
+ classList={{
30
+ "border-red-500": !!props.error,
31
+ }}
32
+ />
33
+ <Show when={props.error}>
34
+ <p class="mt-1 text-sm text-red-600">{props.error}</p>
35
+ </Show>
36
+ </>
37
+ );
38
+ };
@@ -0,0 +1,365 @@
1
+ import { LocaleProvider } from "@/context/locale-context";
2
+ import { dict as lvDict } from "@/locale/lv";
3
+ import { Locale } from "@/types/translations";
4
+ import { fetchLocale } from "@/utils/fetch-locale";
5
+ import { injectFont } from "@/utils/inject-font";
6
+ import { setCssVariablesValue } from "@/utils/set-css-variables";
7
+ import * as i18n from "@solid-primitives/i18n";
8
+ import {
9
+ createEffect,
10
+ createResource,
11
+ createSignal,
12
+ onCleanup,
13
+ Show,
14
+ Suspense,
15
+ } from "solid-js";
16
+
17
+ import type { GameData, Submission } from "../api/client";
18
+ import type { GameContextType } from "../context/GameContext";
19
+ import { EmbedAPIClient } from "../api/client";
20
+ import { GameProvider } from "../context/GameContext";
21
+ import { createGameStore } from "../stores/gameStore";
22
+ import { GameLayout } from "./GameLayout";
23
+ import { GamePlay } from "./GamePlay";
24
+ import { LanguageSwitcher } from "./LanguageSwitcher";
25
+ import { SuccessScreen } from "./SuccessScreen";
26
+ import { WelcomeScreen } from "./WelcomeScreen";
27
+
28
+ export type GameProps = {
29
+ predictionGameId?: string;
30
+ apiBaseUrl?: string;
31
+ email?: string;
32
+ };
33
+
34
+ export const Game = (props: GameProps) => {
35
+ const [isInitialized, setIsInitialized] = createSignal(false);
36
+
37
+ const store = createGameStore(props.email);
38
+
39
+ const initializeGame = async () => {
40
+ if (!props.predictionGameId) {
41
+ store.setError("Prediction game ID is required");
42
+ store.setLoading(false);
43
+ return;
44
+ }
45
+
46
+ try {
47
+ const apiUrl = props.apiBaseUrl || "http://localhost:3000";
48
+ const apiClient = new EmbedAPIClient(apiUrl);
49
+ const data = await apiClient.getGame(props.predictionGameId);
50
+
51
+ store.setGameData(data);
52
+
53
+ // Initialize predictions
54
+ const initialPredictions: Record<string, any> = {};
55
+ data.steps
56
+ .sort((a, b) => a.order - b.order)
57
+ .forEach((step) => {
58
+ if (step.type === "scoreGame") {
59
+ const scores: Record<string, number> = {};
60
+ step.stepValues.forEach((sv) => {
61
+ scores[sv.id] = 0;
62
+ });
63
+ initialPredictions[step.id] = scores;
64
+ }
65
+ });
66
+ store.setPredictions(initialPredictions);
67
+ store.setLoading(false);
68
+ setIsInitialized(true);
69
+ } catch (err) {
70
+ console.error("[Embed] Failed to load game:", err);
71
+ store.setError(
72
+ err instanceof Error ? err.message : "Failed to load prediction game",
73
+ );
74
+ store.setLoading(false);
75
+ }
76
+ };
77
+
78
+ createEffect(() => {
79
+ if (isInitialized()) return;
80
+ initializeGame();
81
+ });
82
+
83
+ onCleanup(() => {
84
+ setIsInitialized(false);
85
+ });
86
+
87
+ return (
88
+ <Suspense>
89
+ <Show when={store.state.customCss}>
90
+ {(css) => <style>{css()}</style>}
91
+ </Show>
92
+ <Show when={store.state.loading}>
93
+ <div class="flex items-center justify-center p-8">
94
+ <div class="text-center">
95
+ <div class="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-current border-r-transparent"></div>
96
+ </div>
97
+ </div>
98
+ </Show>
99
+ <Show when={store.state.error}>
100
+ <div class="rounded border border-red-200 bg-red-50 p-4">
101
+ <p class="text-red-700">{store.state.error}</p>
102
+ </div>
103
+ </Show>
104
+ <Show
105
+ when={
106
+ !store.state.loading &&
107
+ !store.state.error &&
108
+ store.state.gameData &&
109
+ (props.email || !store.state.gameData.settings.requiresEmail)
110
+ }
111
+ >
112
+ <GameContent gameData={store.state.gameData!} store={store} />
113
+ </Show>
114
+ </Suspense>
115
+ );
116
+ };
117
+
118
+ interface GameContentProps {
119
+ gameData: GameData;
120
+ store: ReturnType<typeof createGameStore>;
121
+ }
122
+
123
+ const GameContent = (props: GameContentProps) => {
124
+ let gameContainer: HTMLDivElement | undefined;
125
+ const [locale, setLocale] = createSignal<Locale>("lv");
126
+ const [dict] = createResource(locale, fetchLocale, {
127
+ initialValue: i18n.flatten(lvDict),
128
+ });
129
+ const sortedSteps = () =>
130
+ [...props.gameData.steps].sort((a, b) => a.order - b.order);
131
+
132
+ const useStepByStep = () => sortedSteps().length >= 2;
133
+ const totalSteps = () => (useStepByStep() ? sortedSteps().length : 0);
134
+ const currentGameStep = () =>
135
+ sortedSteps()[props.store.state.currentStepIndex];
136
+
137
+ const handleScorePredictionChange = (
138
+ stepId: string,
139
+ stepValueId: string,
140
+ score: number,
141
+ ) => {
142
+ props.store.updatePrediction(stepId, {
143
+ ...props.store.state.predictions[stepId],
144
+ [stepValueId]: score,
145
+ });
146
+ };
147
+
148
+ const handleCorrectValueChange = (stepId: string, stepValueId: string) => {
149
+ props.store.updatePrediction(stepId, { selectedStepValueId: stepValueId });
150
+ };
151
+
152
+ const validateCurrentStep = (): boolean => {
153
+ const step = currentGameStep();
154
+ if (!step) return false;
155
+
156
+ const prediction = props.store.state.predictions[step.id];
157
+
158
+ if (step.type === "scoreGame") {
159
+ const stepValueIds = step.stepValues.map((sv) => sv.id);
160
+ if (!prediction) {
161
+ props.store.setSubmitError(`Please complete your prediction`);
162
+ return false;
163
+ }
164
+ const hasAllScores = stepValueIds.every(
165
+ (id) =>
166
+ prediction[id] !== undefined && typeof prediction[id] === "number",
167
+ );
168
+ if (!hasAllScores) {
169
+ props.store.setSubmitError(`Please enter all scores`);
170
+ return false;
171
+ }
172
+ } else if (step.type === "correctValue") {
173
+ if (!prediction || !prediction.selectedStepValueId) {
174
+ props.store.setSubmitError(`Please select an option`);
175
+ return false;
176
+ }
177
+ }
178
+
179
+ props.store.setSubmitError("");
180
+ return true;
181
+ };
182
+
183
+ const validateAllPredictions = (): boolean => {
184
+ const steps = sortedSteps();
185
+ for (const step of steps) {
186
+ const prediction = props.store.state.predictions[step.id];
187
+
188
+ if (step.type === "scoreGame") {
189
+ const stepValueIds = step.stepValues.map((sv) => sv.id);
190
+ if (stepValueIds.length !== 2) {
191
+ props.store.setSubmitError("Invalid game configuration");
192
+ return false;
193
+ }
194
+
195
+ if (!prediction) {
196
+ props.store.setSubmitError(
197
+ `Please complete prediction for: ${step.title}`,
198
+ );
199
+ return false;
200
+ }
201
+
202
+ const hasAllScores = stepValueIds.every(
203
+ (id) =>
204
+ prediction[id] !== undefined && typeof prediction[id] === "number",
205
+ );
206
+ if (!hasAllScores) {
207
+ props.store.setSubmitError(`Please enter scores for: ${step.title}`);
208
+ return false;
209
+ }
210
+ } else if (step.type === "correctValue") {
211
+ if (!prediction || !prediction.selectedStepValueId) {
212
+ props.store.setSubmitError(
213
+ `Please select an option for: ${step.title}`,
214
+ );
215
+ return false;
216
+ }
217
+ }
218
+ }
219
+
220
+ props.store.setSubmitError("");
221
+ return true;
222
+ };
223
+
224
+ const handleNext = () => {
225
+ if (!validateCurrentStep()) return;
226
+ props.store.setSubmitError("");
227
+ if (props.store.state.currentStepIndex < totalSteps() - 1) {
228
+ props.store.setCurrentStepIndex(props.store.state.currentStepIndex + 1);
229
+ }
230
+ };
231
+
232
+ const handleSubmit = async () => {
233
+ if (!useStepByStep()) {
234
+ if (!validateAllPredictions()) return;
235
+ } else {
236
+ if (!validateCurrentStep()) return;
237
+ }
238
+
239
+ props.store.setSubmitting(true);
240
+ props.store.setSubmitError("");
241
+
242
+ try {
243
+ const apiUrl = props.gameData.id.includes("localhost")
244
+ ? "http://localhost:3000"
245
+ : "https://play.useaward.com";
246
+
247
+ const apiClient = new EmbedAPIClient(apiUrl);
248
+
249
+ const submissions: Submission[] = props.gameData.steps.map((step) => {
250
+ const prediction = props.store.state.predictions[step.id];
251
+
252
+ if (step.type === "scoreGame") {
253
+ return {
254
+ stepId: step.id,
255
+ values: step.stepValues.map((sv) => ({
256
+ stepValueId: sv.id,
257
+ predictedScore: prediction[sv.id] || 0,
258
+ })),
259
+ };
260
+ } else {
261
+ return {
262
+ stepId: step.id,
263
+ values: [
264
+ {
265
+ stepValueId: prediction.selectedStepValueId,
266
+ isSelected: true,
267
+ },
268
+ ],
269
+ };
270
+ }
271
+ });
272
+
273
+ await apiClient.submitPrediction(
274
+ props.gameData.id,
275
+ props.store.state.email,
276
+ submissions,
277
+ );
278
+
279
+ props.store.setSubmitted(true);
280
+ } catch (err: any) {
281
+ console.error("Submission error:", err);
282
+ props.store.setSubmitError(
283
+ err?.message || "Failed to submit predictions. Please try again.",
284
+ );
285
+ } finally {
286
+ props.store.setSubmitting(false);
287
+ }
288
+ };
289
+
290
+ const contextValue: GameContextType = {
291
+ gameData: props.gameData,
292
+ store: props.store,
293
+ sortedSteps,
294
+ currentStep: currentGameStep,
295
+ totalSteps,
296
+ useStepByStep,
297
+ handleNext,
298
+ handleSubmit,
299
+ handleScorePredictionChange,
300
+ handleCorrectValueChange,
301
+ };
302
+
303
+ createEffect(() => {
304
+ injectFont(props.gameData.settings.fontFamily);
305
+ if (!gameContainer) return;
306
+
307
+ setCssVariablesValue({
308
+ theme: {
309
+ questionTextColor: props.gameData.settings.questionTextColor,
310
+ buttonBackground: props.gameData.settings.buttonBackground,
311
+ buttonTextColor: props.gameData.settings.buttonTextColor,
312
+ backgroundColor: props.gameData.settings.backgroundColor,
313
+ selectionBackground: props.gameData.settings.selectionBackground,
314
+ selectionTextColor: props.gameData.settings.selectionTextColor,
315
+ },
316
+ container: gameContainer,
317
+ });
318
+ });
319
+
320
+ return (
321
+ <Show when={dict()}>
322
+ {(dict) => {
323
+ const t = i18n.translator(dict, i18n.resolveTemplate);
324
+
325
+ return (
326
+ <LocaleProvider value={{ t, locale, setLocale }}>
327
+ <GameProvider value={contextValue}>
328
+ <div ref={gameContainer} class="w-full antialiased">
329
+ <div class="flex flex-col items-center justify-center gap-4 rounded-lg bg-white p-4">
330
+ <div class="flex w-full items-center justify-end">
331
+ <LanguageSwitcher />
332
+ </div>
333
+
334
+ <GameLayout>
335
+ <Show
336
+ when={
337
+ !props.store.state.hasStarted &&
338
+ !props.store.state.submitted
339
+ }
340
+ >
341
+ <WelcomeScreen />
342
+ </Show>
343
+
344
+ <Show
345
+ when={
346
+ props.store.state.hasStarted &&
347
+ !props.store.state.submitted
348
+ }
349
+ >
350
+ <GamePlay />
351
+ </Show>
352
+
353
+ <Show when={props.store.state.submitted}>
354
+ <SuccessScreen />
355
+ </Show>
356
+ </GameLayout>
357
+ </div>
358
+ </div>
359
+ </GameProvider>
360
+ </LocaleProvider>
361
+ );
362
+ }}
363
+ </Show>
364
+ );
365
+ };