@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/dist/web.js +870 -0
- package/package.json +49 -0
- package/src/api/client.ts +107 -0
- package/src/assets/index.css +15 -0
- package/src/components/ButtonBar.tsx +52 -0
- package/src/components/CorrectValueStep.tsx +80 -0
- package/src/components/EmailInput.tsx +38 -0
- package/src/components/Game.tsx +365 -0
- package/src/components/GameLayout.tsx +29 -0
- package/src/components/GamePlay.tsx +77 -0
- package/src/components/LanguageSwitcher.tsx +40 -0
- package/src/components/ScoreGameStep.tsx +113 -0
- package/src/components/StepRenderer.tsx +41 -0
- package/src/components/SuccessScreen.tsx +24 -0
- package/src/components/WelcomeScreen.tsx +43 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/spinner.tsx +18 -0
- package/src/constants.ts +27 -0
- package/src/context/GameContext.tsx +43 -0
- package/src/context/locale-context.tsx +32 -0
- package/src/features/standard/StandardGameEmbed.tsx +57 -0
- package/src/global.d.ts +1 -0
- package/src/locale/en.ts +24 -0
- package/src/locale/lv.ts +24 -0
- package/src/register.ts +13 -0
- package/src/stores/gameStore.ts +101 -0
- package/src/types/style.ts +9 -0
- package/src/types/translations.ts +8 -0
- package/src/utils/cn.ts +7 -0
- package/src/utils/fetch-locale.ts +8 -0
- package/src/utils/inject-font.ts +23 -0
- package/src/utils/set-css-variables.ts +83 -0
- package/src/web.ts +12 -0
- package/src/window.ts +33 -0
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,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
|
+
};
|