├── .eslintignore
├── .eslintrc.json
├── .gitignore
├── .husky
├── .gitignore
└── pre-commit
├── .prettierignore
├── .prettierrc
├── CODEOWNERS
├── CODE_OF_CONDUCT.md
├── LICENSE
├── Procfile
├── README.md
├── SECURITY.md
├── app.json
├── bin
└── set-project-version.sh
├── lwr.config.json
├── package-lock.json
├── package.json
├── src
├── client
│ ├── assets
│ │ ├── dist
│ │ │ └── normalize.css
│ │ ├── favicon.ico
│ │ ├── logo.png
│ │ └── slds-icons-action.svg
│ ├── layouts
│ │ └── index.html
│ ├── modules
│ │ ├── services
│ │ │ ├── answer
│ │ │ │ └── answer.js
│ │ │ ├── configuration
│ │ │ │ └── configuration.js
│ │ │ ├── player
│ │ │ │ └── player.js
│ │ │ └── session
│ │ │ │ └── session.js
│ │ ├── ui
│ │ │ ├── app
│ │ │ │ ├── app.css
│ │ │ │ ├── app.html
│ │ │ │ └── app.js
│ │ │ ├── footer
│ │ │ │ ├── footer.css
│ │ │ │ ├── footer.html
│ │ │ │ └── footer.js
│ │ │ ├── header
│ │ │ │ ├── header.css
│ │ │ │ ├── header.html
│ │ │ │ └── header.js
│ │ │ ├── input
│ │ │ │ ├── input.css
│ │ │ │ ├── input.html
│ │ │ │ └── input.js
│ │ │ ├── playerStats
│ │ │ │ ├── playerStats.css
│ │ │ │ ├── playerStats.html
│ │ │ │ └── playerStats.js
│ │ │ ├── question
│ │ │ │ ├── question.css
│ │ │ │ ├── question.html
│ │ │ │ └── question.js
│ │ │ ├── registrationForm
│ │ │ │ ├── registrationForm.css
│ │ │ │ ├── registrationForm.html
│ │ │ │ └── registrationForm.js
│ │ │ └── spinner
│ │ │ │ ├── spinner.css
│ │ │ │ ├── spinner.html
│ │ │ │ └── spinner.js
│ │ └── utils
│ │ │ ├── cookies
│ │ │ └── cookies.js
│ │ │ ├── error
│ │ │ └── error.js
│ │ │ ├── fetch
│ │ │ └── fetch.js
│ │ │ └── webSocketClient
│ │ │ └── webSocketClient.js
│ └── resources
│ │ └── dist
│ │ └── normalize.css
└── server
│ ├── rest
│ ├── answer.js
│ ├── configuration.js
│ ├── player.js
│ └── quiz-session.js
│ ├── server.js
│ └── utils
│ ├── configuration.js
│ └── webSocketService.js
└── test-scripts
└── change-phase.sh
/.eslintignore:
--------------------------------------------------------------------------------
1 | dist/
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@salesforce/eslint-config-lwc/recommended"],
3 | "rules": {
4 | "@lwc/lwc/no-async-operation": "warn",
5 | "@lwc/lwc/no-inner-html": "warn",
6 | "@lwc/lwc/no-document-query": "warn",
7 | "@lwc/lwc/no-unknown-wire-adapters": "off"
8 | },
9 | "overrides": [
10 | {
11 | "files": ["src/server/**"],
12 | "env": {
13 | "node": true
14 | },
15 | "rules": {
16 | "no-console": "off"
17 | }
18 | }
19 | ]
20 | }
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Log files
2 | logs
3 | *.log
4 | *-debug.log
5 | *-error.log
6 |
7 | # Secret configuration
8 | .env
9 |
10 | # Standard dist folder
11 | /dist
12 |
13 | # Tooling files
14 | node_modules
15 | jsconfig.json
16 | .vscode
17 | __lwr_cache__
18 |
19 | # Temp directory
20 | /tmp
21 |
22 | # Jest coverage folder
23 | /coverage
24 |
25 | # MacOS system files
26 | .DS_Store
27 |
28 | # Windows system files
29 | Thumbs.db
30 | ehthumbs.db
31 | [Dd]esktop.ini
32 | $RECYCLE.BIN/
--------------------------------------------------------------------------------
/.husky/.gitignore:
--------------------------------------------------------------------------------
1 | _
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | . "$(dirname "$0")/_/husky.sh"
3 |
4 | npm run precommit
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | # Jest coverage
2 | coverage/
3 |
4 | # Default build folder
5 | dist/
6 | __lwr_cache__
7 |
8 | # Default assets folder
9 | src/client/assets
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "none",
3 | "singleQuote": true,
4 | "tabWidth": 4,
5 | "overrides": [
6 | {
7 | "files": "**/*.html",
8 | "options": { "parser": "lwc" }
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | # Comment line immediately above ownership line is reserved for related gus information. Please be careful while editing.
2 | #ECCN:Open Source
3 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Salesforce Open Source Community Code of Conduct
2 |
3 | ## About the Code of Conduct
4 |
5 | Equality is a core value at Salesforce. We believe a diverse and inclusive
6 | community fosters innovation and creativity, and are committed to building a
7 | culture where everyone feels included.
8 |
9 | Salesforce open-source projects are committed to providing a friendly, safe, and
10 | welcoming environment for all, regardless of gender identity and expression,
11 | sexual orientation, disability, physical appearance, body size, ethnicity, nationality,
12 | race, age, religion, level of experience, education, socioeconomic status, or
13 | other similar personal characteristics.
14 |
15 | The goal of this code of conduct is to specify a baseline standard of behavior so
16 | that people with different social values and communication styles can work
17 | together effectively, productively, and respectfully in our open source community.
18 | It also establishes a mechanism for reporting issues and resolving conflicts.
19 |
20 | All questions and reports of abusive, harassing, or otherwise unacceptable behavior
21 | in a Salesforce open-source project may be reported by contacting the Salesforce
22 | Open Source Conduct Committee at ossconduct@salesforce.com.
23 |
24 | ## Our Pledge
25 |
26 | In the interest of fostering an open and welcoming environment, we as
27 | contributors and maintainers pledge to making participation in our project and
28 | our community a harassment-free experience for everyone, regardless of gender
29 | identity and expression, sexual orientation, disability, physical appearance,
30 | body size, ethnicity, nationality, race, age, religion, level of experience, education,
31 | socioeconomic status, or other similar personal characteristics.
32 |
33 | ## Our Standards
34 |
35 | Examples of behavior that contributes to creating a positive environment
36 | include:
37 |
38 | - Using welcoming and inclusive language
39 | - Being respectful of differing viewpoints and experiences
40 | - Gracefully accepting constructive criticism
41 | - Focusing on what is best for the community
42 | - Showing empathy toward other community members
43 |
44 | Examples of unacceptable behavior by participants include:
45 |
46 | - The use of sexualized language or imagery and unwelcome sexual attention or
47 | advances
48 | - Personal attacks, insulting/derogatory comments, or trolling
49 | - Public or private harassment
50 | - Publishing, or threatening to publish, others' private information—such as
51 | a physical or electronic address—without explicit permission
52 | - Other conduct which could reasonably be considered inappropriate in a
53 | professional setting
54 | - Advocating for or encouraging any of the above behaviors
55 |
56 | ## Our Responsibilities
57 |
58 | Project maintainers are responsible for clarifying the standards of acceptable
59 | behavior and are expected to take appropriate and fair corrective action in
60 | response to any instances of unacceptable behavior.
61 |
62 | Project maintainers have the right and responsibility to remove, edit, or
63 | reject comments, commits, code, wiki edits, issues, and other contributions
64 | that are not aligned with this Code of Conduct, or to ban temporarily or
65 | permanently any contributor for other behaviors that they deem inappropriate,
66 | threatening, offensive, or harmful.
67 |
68 | ## Scope
69 |
70 | This Code of Conduct applies both within project spaces and in public spaces
71 | when an individual is representing the project or its community. Examples of
72 | representing a project or community include using an official project email
73 | address, posting via an official social media account, or acting as an appointed
74 | representative at an online or offline event. Representation of a project may be
75 | further defined and clarified by project maintainers.
76 |
77 | ## Enforcement
78 |
79 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
80 | reported by contacting the Salesforce Open Source Conduct Committee
81 | at ossconduct@salesforce.com. All complaints will be reviewed and investigated
82 | and will result in a response that is deemed necessary and appropriate to the
83 | circumstances. The committee is obligated to maintain confidentiality with
84 | regard to the reporter of an incident. Further details of specific enforcement
85 | policies may be posted separately.
86 |
87 | Project maintainers who do not follow or enforce the Code of Conduct in good
88 | faith may face temporary or permanent repercussions as determined by other
89 | members of the project's leadership and the Salesforce Open Source Conduct
90 | Committee.
91 |
92 | ## Attribution
93 |
94 | This Code of Conduct is adapted from the [Contributor Covenant][contributor-covenant-home],
95 | version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html.
96 | It includes adaptions and additions from [Go Community Code of Conduct][golang-coc],
97 | [CNCF Code of Conduct][cncf-coc], and [Microsoft Open Source Code of Conduct][microsoft-coc].
98 |
99 | This Code of Conduct is licensed under the [Creative Commons Attribution 3.0 License][cc-by-3-us].
100 |
101 | [contributor-covenant-home]: https://www.contributor-covenant.org 'https://www.contributor-covenant.org/'
102 | [golang-coc]: https://golang.org/conduct
103 | [cncf-coc]: https://github.com/cncf/foundation/blob/master/code-of-conduct.md
104 | [microsoft-coc]: https://opensource.microsoft.com/codeofconduct/
105 | [cc-by-3-us]: https://creativecommons.org/licenses/by/3.0/us/
106 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | CC0 1.0 Universal
2 |
3 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
4 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
5 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
6 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
7 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
8 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
9 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
10 | HEREUNDER.
11 |
12 | Statement of Purpose
13 |
14 | The laws of most jurisdictions throughout the world automatically confer
15 | exclusive Copyright and Related Rights (defined below) upon the creator
16 | and subsequent owner(s) (each and all, an "owner") of an original work of
17 | authorship and/or a database (each, a "Work").
18 |
19 | Certain owners wish to permanently relinquish those rights to a Work for
20 | the purpose of contributing to a commons of creative, cultural and
21 | scientific works ("Commons") that the public can reliably and without fear
22 | of later claims of infringement build upon, modify, incorporate in other
23 | works, reuse and redistribute as freely as possible in any form whatsoever
24 | and for any purposes, including without limitation commercial purposes.
25 | These owners may contribute to the Commons to promote the ideal of a free
26 | culture and the further production of creative, cultural and scientific
27 | works, or to gain reputation or greater distribution for their Work in
28 | part through the use and efforts of others.
29 |
30 | For these and/or other purposes and motivations, and without any
31 | expectation of additional consideration or compensation, the person
32 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she
33 | is an owner of Copyright and Related Rights in the Work, voluntarily
34 | elects to apply CC0 to the Work and publicly distribute the Work under its
35 | terms, with knowledge of his or her Copyright and Related Rights in the
36 | Work and the meaning and intended legal effect of CC0 on those rights.
37 |
38 | 1. Copyright and Related Rights. A Work made available under CC0 may be
39 | protected by copyright and related or neighboring rights ("Copyright and
40 | Related Rights"). Copyright and Related Rights include, but are not
41 | limited to, the following:
42 |
43 | i. the right to reproduce, adapt, distribute, perform, display,
44 | communicate, and translate a Work;
45 | ii. moral rights retained by the original author(s) and/or performer(s);
46 | iii. publicity and privacy rights pertaining to a person's image or
47 | likeness depicted in a Work;
48 | iv. rights protecting against unfair competition in regards to a Work,
49 | subject to the limitations in paragraph 4(a), below;
50 | v. rights protecting the extraction, dissemination, use and reuse of data
51 | in a Work;
52 | vi. database rights (such as those arising under Directive 96/9/EC of the
53 | European Parliament and of the Council of 11 March 1996 on the legal
54 | protection of databases, and under any national implementation
55 | thereof, including any amended or successor version of such
56 | directive); and
57 | vii. other similar, equivalent or corresponding rights throughout the
58 | world based on applicable law or treaty, and any national
59 | implementations thereof.
60 |
61 | 2. Waiver. To the greatest extent permitted by, but not in contravention
62 | of, applicable law, Affirmer hereby overtly, fully, permanently,
63 | irrevocably and unconditionally waives, abandons, and surrenders all of
64 | Affirmer's Copyright and Related Rights and associated claims and causes
65 | of action, whether now known or unknown (including existing as well as
66 | future claims and causes of action), in the Work (i) in all territories
67 | worldwide, (ii) for the maximum duration provided by applicable law or
68 | treaty (including future time extensions), (iii) in any current or future
69 | medium and for any number of copies, and (iv) for any purpose whatsoever,
70 | including without limitation commercial, advertising or promotional
71 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
72 | member of the public at large and to the detriment of Affirmer's heirs and
73 | successors, fully intending that such Waiver shall not be subject to
74 | revocation, rescission, cancellation, termination, or any other legal or
75 | equitable action to disrupt the quiet enjoyment of the Work by the public
76 | as contemplated by Affirmer's express Statement of Purpose.
77 |
78 | 3. Public License Fallback. Should any part of the Waiver for any reason
79 | be judged legally invalid or ineffective under applicable law, then the
80 | Waiver shall be preserved to the maximum extent permitted taking into
81 | account Affirmer's express Statement of Purpose. In addition, to the
82 | extent the Waiver is so judged Affirmer hereby grants to each affected
83 | person a royalty-free, non transferable, non sublicensable, non exclusive,
84 | irrevocable and unconditional license to exercise Affirmer's Copyright and
85 | Related Rights in the Work (i) in all territories worldwide, (ii) for the
86 | maximum duration provided by applicable law or treaty (including future
87 | time extensions), (iii) in any current or future medium and for any number
88 | of copies, and (iv) for any purpose whatsoever, including without
89 | limitation commercial, advertising or promotional purposes (the
90 | "License"). The License shall be deemed effective as of the date CC0 was
91 | applied by Affirmer to the Work. Should any part of the License for any
92 | reason be judged legally invalid or ineffective under applicable law, such
93 | partial invalidity or ineffectiveness shall not invalidate the remainder
94 | of the License, and in such case Affirmer hereby affirms that he or she
95 | will not (i) exercise any of his or her remaining Copyright and Related
96 | Rights in the Work or (ii) assert any associated claims and causes of
97 | action with respect to the Work, in either case contrary to Affirmer's
98 | express Statement of Purpose.
99 |
100 | 4. Limitations and Disclaimers.
101 |
102 | a. No trademark or patent rights held by Affirmer are waived, abandoned,
103 | surrendered, licensed or otherwise affected by this document.
104 | b. Affirmer offers the Work as-is and makes no representations or
105 | warranties of any kind concerning the Work, express, implied,
106 | statutory or otherwise, including without limitation warranties of
107 | title, merchantability, fitness for a particular purpose, non
108 | infringement, or the absence of latent or other defects, accuracy, or
109 | the present or absence of errors, whether or not discoverable, all to
110 | the greatest extent permissible under applicable law.
111 | c. Affirmer disclaims responsibility for clearing rights of other persons
112 | that may apply to the Work or any use thereof, including without
113 | limitation any person's Copyright and Related Rights in the Work.
114 | Further, Affirmer disclaims responsibility for obtaining any necessary
115 | consents, permissions or other rights required for any use of the
116 | Work.
117 | d. Affirmer understands and acknowledges that Creative Commons is not a
118 | party to this document and has no duty or obligation with respect to
119 | this CC0 or use of the Work.
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: npm start
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Multiplayer quiz app built on Salesforce technology (player app)
2 |
3 | ℹ️ Please refer to the [quiz host app](https://github.com/forcedotcom/quiz-host-app) for documentation.
4 |
5 | ## Heroku deploy (recommended)
6 |
7 | Click on this button and follow the instructions to deploy the app:
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | ## Local setup (development only)
16 |
17 | Create a `.env` file at the root of the project:
18 |
19 | ```properties
20 | SF_LOGIN_URL='https://test.salesforce.com'
21 | SF_API_VERSION='55.0'
22 | SF_USERNAME='YOUR_SALESFORCE_USERNAME'
23 | SF_PASSWORD='YOUR_SALESFORCE_PASSWORD'
24 | SF_TOKEN='YOUR_SALESFORCE_SECURITY_TOKEN'
25 | SF_NAMESPACE=''
26 | QUIZ_API_KEY='YOUR_QUIZ_API_KEY'
27 | COLLECT_PLAYER_EMAILS=false
28 | ```
29 |
30 | Run the project with `npm start`
31 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | ## Security
2 |
3 | Please report any security issue to [security@salesforce.com](mailto:security@salesforce.com)
4 | as soon as it is discovered. This library limits its runtime dependencies in
5 | order to reduce the total cost of ownership as much as can be, but all consumers
6 | should remain vigilant and have their security stakeholders review all third-party
7 | products (3PP) like this one and their dependencies.
8 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Quiz Player App",
3 | "description": "Multiplayer quiz app built on Salesforce technology (player app)",
4 | "repository": "https://github.com/forcedotcom/quiz-player-app",
5 | "logo": "https://github.com/forcedotcom/quiz-player-app/raw/master/src/client/assets/logo.png",
6 | "keywords": ["lightning", "salesforce", "web-components", "open source"],
7 | "env": {
8 | "SF_LOGIN_URL": {
9 | "description": "Salesforce authentication domain",
10 | "value": "https://test.salesforce.com/",
11 | "required": true
12 | },
13 | "SF_API_VERSION": {
14 | "description": "Salesforce API version",
15 | "value": "60.0",
16 | "required": true
17 | },
18 | "SF_USERNAME": {
19 | "description": "Salesforce username",
20 | "value": "",
21 | "required": true
22 | },
23 | "SF_PASSWORD": {
24 | "description": "Salesforce password",
25 | "value": "",
26 | "required": true
27 | },
28 | "SF_TOKEN": {
29 | "description": "Salesforce security token",
30 | "value": "",
31 | "required": true
32 | },
33 | "SF_NAMESPACE": {
34 | "description": "Salesforce package namespace (default: sfq)",
35 | "value": "sfq"
36 | },
37 | "QUIZ_API_KEY": {
38 | "description": "Quiz API key",
39 | "value": "",
40 | "required": true
41 | },
42 | "COLLECT_PLAYER_EMAILS": {
43 | "description": "Whether app should collect player emails (true/false)",
44 | "value": "false"
45 | }
46 | },
47 | "success_url": "/"
48 | }
49 |
--------------------------------------------------------------------------------
/bin/set-project-version.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | SCRIPT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
3 | cd $SCRIPT_PATH
4 |
5 | version="l"
6 | if [ "$#" -eq 1 ]; then
7 | version="$1"
8 | else
9 | echo "Missing project version argument"
10 | exit -1
11 | fi
12 |
13 | sed -E -i '' -e "s|\"version\": \"[0-9]+\.[0-9]+\.[0-9]+\",|\"version\": \"$version\",|" ../package.json && \
14 | sed -E -i '' -e "s|PLAYER_APP_VERSION = '[0-9]+\.[0-9]+\.[0-9]+';|PLAYER_APP_VERSION = '$version';|" ../src/client/modules/ui/app/app.js && \
15 | npm install
16 |
17 | EXIT_CODE="$?"
18 |
19 | # Check exit code
20 | echo ""
21 | if [ "$EXIT_CODE" -eq 0 ]; then
22 | echo "Version set to $version"
23 | else
24 | echo "Failed to set version $version"
25 | fi
26 | exit $EXIT_CODE
27 |
--------------------------------------------------------------------------------
/lwr.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "layoutsDir": "$rootDir/src/client/layouts",
3 | "lwc": {
4 | "modules": [{ "dir": "$rootDir/src/client/modules" }]
5 | },
6 | "bundleConfig": { "exclude": ["lwc"] },
7 | "assets": [
8 | {
9 | "alias": "assetsDir",
10 | "dir": "$rootDir/src/client/assets",
11 | "urlPath": "/assets"
12 | }
13 | ],
14 | "routes": [
15 | {
16 | "id": "home",
17 | "path": "/",
18 | "rootComponent": "ui/app",
19 | "layoutTemplate": "$layoutsDir/index.html"
20 | }
21 | ]
22 | }
23 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "quiz-player-app",
3 | "description": "Multiplayer quiz app built on Salesforce technology (player app)",
4 | "version": "3.0.2",
5 | "private": true,
6 | "author": "pozil",
7 | "bugs": "https://github.com/developerforce/quiz-player-app/issues",
8 | "dependencies": {
9 | "@babel/core": "^7.24.4",
10 | "dotenv": "^16.4.5",
11 | "jsforce": "^1.11.1",
12 | "lwc": "^6.5.3",
13 | "lwr": "^0.12.2",
14 | "normalize.css": "^8.0.1",
15 | "ws": "^8.16.0"
16 | },
17 | "devDependencies": {
18 | "@lwc/eslint-plugin-lwc": "^1.8.0",
19 | "@lwc/jest-preset": "^16.0.0",
20 | "@salesforce/eslint-config-lwc": "^3.5.3",
21 | "@salesforce/eslint-plugin-lightning": "^1.0.0",
22 | "eslint": "^8.57.0",
23 | "eslint-plugin-import": "^2.29.1",
24 | "eslint-plugin-jest": "^28.2.0",
25 | "husky": "^9.0.11",
26 | "jest": "^29.7.0",
27 | "lint-staged": "^15.2.2",
28 | "prettier": "^3.2.5"
29 | },
30 | "engines": {
31 | "node": "^20"
32 | },
33 | "homepage": "https://github.com/developerforce/quiz-player-app",
34 | "keywords": [
35 | "lwc"
36 | ],
37 | "license": "CC0-1.0",
38 | "lint-staged": {
39 | "**/*.{css,html,js,json,md,yaml,yml}": [
40 | "prettier --write"
41 | ],
42 | "**/modules/**/*.js": [
43 | "eslint"
44 | ]
45 | },
46 | "repository": "pozil/quiz",
47 | "scripts": {
48 | "start": "MODE=prod node src/server/server.js",
49 | "dev": "node src/server/server.js",
50 | "lint": "eslint ./src/**/*.js",
51 | "prettier": "prettier --write '**/*.{css,html,js,json,md,yaml,yml}'",
52 | "prettier:verify": "prettier --list-different '**/*.{css,html,js,json,md,yaml,yml}'",
53 | "preinstall": "rm -fr src/client/assets/dist",
54 | "postinstall": "husky install && mkdir -p src/client/assets/dist && cp node_modules/normalize.css/normalize.css src/client/assets/dist/.",
55 | "precommit": "lint-staged"
56 | },
57 | "volta": {
58 | "node": "20.12.2"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/client/assets/dist/normalize.css:
--------------------------------------------------------------------------------
1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
2 |
3 | /* Document
4 | ========================================================================== */
5 |
6 | /**
7 | * 1. Correct the line height in all browsers.
8 | * 2. Prevent adjustments of font size after orientation changes in iOS.
9 | */
10 |
11 | html {
12 | line-height: 1.15; /* 1 */
13 | -webkit-text-size-adjust: 100%; /* 2 */
14 | }
15 |
16 | /* Sections
17 | ========================================================================== */
18 |
19 | /**
20 | * Remove the margin in all browsers.
21 | */
22 |
23 | body {
24 | margin: 0;
25 | }
26 |
27 | /**
28 | * Render the `main` element consistently in IE.
29 | */
30 |
31 | main {
32 | display: block;
33 | }
34 |
35 | /**
36 | * Correct the font size and margin on `h1` elements within `section` and
37 | * `article` contexts in Chrome, Firefox, and Safari.
38 | */
39 |
40 | h1 {
41 | font-size: 2em;
42 | margin: 0.67em 0;
43 | }
44 |
45 | /* Grouping content
46 | ========================================================================== */
47 |
48 | /**
49 | * 1. Add the correct box sizing in Firefox.
50 | * 2. Show the overflow in Edge and IE.
51 | */
52 |
53 | hr {
54 | box-sizing: content-box; /* 1 */
55 | height: 0; /* 1 */
56 | overflow: visible; /* 2 */
57 | }
58 |
59 | /**
60 | * 1. Correct the inheritance and scaling of font size in all browsers.
61 | * 2. Correct the odd `em` font sizing in all browsers.
62 | */
63 |
64 | pre {
65 | font-family: monospace, monospace; /* 1 */
66 | font-size: 1em; /* 2 */
67 | }
68 |
69 | /* Text-level semantics
70 | ========================================================================== */
71 |
72 | /**
73 | * Remove the gray background on active links in IE 10.
74 | */
75 |
76 | a {
77 | background-color: transparent;
78 | }
79 |
80 | /**
81 | * 1. Remove the bottom border in Chrome 57-
82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
83 | */
84 |
85 | abbr[title] {
86 | border-bottom: none; /* 1 */
87 | text-decoration: underline; /* 2 */
88 | text-decoration: underline dotted; /* 2 */
89 | }
90 |
91 | /**
92 | * Add the correct font weight in Chrome, Edge, and Safari.
93 | */
94 |
95 | b,
96 | strong {
97 | font-weight: bolder;
98 | }
99 |
100 | /**
101 | * 1. Correct the inheritance and scaling of font size in all browsers.
102 | * 2. Correct the odd `em` font sizing in all browsers.
103 | */
104 |
105 | code,
106 | kbd,
107 | samp {
108 | font-family: monospace, monospace; /* 1 */
109 | font-size: 1em; /* 2 */
110 | }
111 |
112 | /**
113 | * Add the correct font size in all browsers.
114 | */
115 |
116 | small {
117 | font-size: 80%;
118 | }
119 |
120 | /**
121 | * Prevent `sub` and `sup` elements from affecting the line height in
122 | * all browsers.
123 | */
124 |
125 | sub,
126 | sup {
127 | font-size: 75%;
128 | line-height: 0;
129 | position: relative;
130 | vertical-align: baseline;
131 | }
132 |
133 | sub {
134 | bottom: -0.25em;
135 | }
136 |
137 | sup {
138 | top: -0.5em;
139 | }
140 |
141 | /* Embedded content
142 | ========================================================================== */
143 |
144 | /**
145 | * Remove the border on images inside links in IE 10.
146 | */
147 |
148 | img {
149 | border-style: none;
150 | }
151 |
152 | /* Forms
153 | ========================================================================== */
154 |
155 | /**
156 | * 1. Change the font styles in all browsers.
157 | * 2. Remove the margin in Firefox and Safari.
158 | */
159 |
160 | button,
161 | input,
162 | optgroup,
163 | select,
164 | textarea {
165 | font-family: inherit; /* 1 */
166 | font-size: 100%; /* 1 */
167 | line-height: 1.15; /* 1 */
168 | margin: 0; /* 2 */
169 | }
170 |
171 | /**
172 | * Show the overflow in IE.
173 | * 1. Show the overflow in Edge.
174 | */
175 |
176 | button,
177 | input { /* 1 */
178 | overflow: visible;
179 | }
180 |
181 | /**
182 | * Remove the inheritance of text transform in Edge, Firefox, and IE.
183 | * 1. Remove the inheritance of text transform in Firefox.
184 | */
185 |
186 | button,
187 | select { /* 1 */
188 | text-transform: none;
189 | }
190 |
191 | /**
192 | * Correct the inability to style clickable types in iOS and Safari.
193 | */
194 |
195 | button,
196 | [type="button"],
197 | [type="reset"],
198 | [type="submit"] {
199 | -webkit-appearance: button;
200 | }
201 |
202 | /**
203 | * Remove the inner border and padding in Firefox.
204 | */
205 |
206 | button::-moz-focus-inner,
207 | [type="button"]::-moz-focus-inner,
208 | [type="reset"]::-moz-focus-inner,
209 | [type="submit"]::-moz-focus-inner {
210 | border-style: none;
211 | padding: 0;
212 | }
213 |
214 | /**
215 | * Restore the focus styles unset by the previous rule.
216 | */
217 |
218 | button:-moz-focusring,
219 | [type="button"]:-moz-focusring,
220 | [type="reset"]:-moz-focusring,
221 | [type="submit"]:-moz-focusring {
222 | outline: 1px dotted ButtonText;
223 | }
224 |
225 | /**
226 | * Correct the padding in Firefox.
227 | */
228 |
229 | fieldset {
230 | padding: 0.35em 0.75em 0.625em;
231 | }
232 |
233 | /**
234 | * 1. Correct the text wrapping in Edge and IE.
235 | * 2. Correct the color inheritance from `fieldset` elements in IE.
236 | * 3. Remove the padding so developers are not caught out when they zero out
237 | * `fieldset` elements in all browsers.
238 | */
239 |
240 | legend {
241 | box-sizing: border-box; /* 1 */
242 | color: inherit; /* 2 */
243 | display: table; /* 1 */
244 | max-width: 100%; /* 1 */
245 | padding: 0; /* 3 */
246 | white-space: normal; /* 1 */
247 | }
248 |
249 | /**
250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera.
251 | */
252 |
253 | progress {
254 | vertical-align: baseline;
255 | }
256 |
257 | /**
258 | * Remove the default vertical scrollbar in IE 10+.
259 | */
260 |
261 | textarea {
262 | overflow: auto;
263 | }
264 |
265 | /**
266 | * 1. Add the correct box sizing in IE 10.
267 | * 2. Remove the padding in IE 10.
268 | */
269 |
270 | [type="checkbox"],
271 | [type="radio"] {
272 | box-sizing: border-box; /* 1 */
273 | padding: 0; /* 2 */
274 | }
275 |
276 | /**
277 | * Correct the cursor style of increment and decrement buttons in Chrome.
278 | */
279 |
280 | [type="number"]::-webkit-inner-spin-button,
281 | [type="number"]::-webkit-outer-spin-button {
282 | height: auto;
283 | }
284 |
285 | /**
286 | * 1. Correct the odd appearance in Chrome and Safari.
287 | * 2. Correct the outline style in Safari.
288 | */
289 |
290 | [type="search"] {
291 | -webkit-appearance: textfield; /* 1 */
292 | outline-offset: -2px; /* 2 */
293 | }
294 |
295 | /**
296 | * Remove the inner padding in Chrome and Safari on macOS.
297 | */
298 |
299 | [type="search"]::-webkit-search-decoration {
300 | -webkit-appearance: none;
301 | }
302 |
303 | /**
304 | * 1. Correct the inability to style clickable types in iOS and Safari.
305 | * 2. Change font properties to `inherit` in Safari.
306 | */
307 |
308 | ::-webkit-file-upload-button {
309 | -webkit-appearance: button; /* 1 */
310 | font: inherit; /* 2 */
311 | }
312 |
313 | /* Interactive
314 | ========================================================================== */
315 |
316 | /*
317 | * Add the correct display in Edge, IE 10+, and Firefox.
318 | */
319 |
320 | details {
321 | display: block;
322 | }
323 |
324 | /*
325 | * Add the correct display in all browsers.
326 | */
327 |
328 | summary {
329 | display: list-item;
330 | }
331 |
332 | /* Misc
333 | ========================================================================== */
334 |
335 | /**
336 | * Add the correct display in IE 10+.
337 | */
338 |
339 | template {
340 | display: none;
341 | }
342 |
343 | /**
344 | * Add the correct display in IE 10.
345 | */
346 |
347 | [hidden] {
348 | display: none;
349 | }
350 |
--------------------------------------------------------------------------------
/src/client/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/forcedotcom/quiz-player-app/e4cbad51a719c736d92d71504bee65945c0e709c/src/client/assets/favicon.ico
--------------------------------------------------------------------------------
/src/client/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/forcedotcom/quiz-player-app/e4cbad51a719c736d92d71504bee65945c0e709c/src/client/assets/logo.png
--------------------------------------------------------------------------------
/src/client/assets/slds-icons-action.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/client/layouts/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Quiz
6 |
15 |
16 |
17 |
18 |
19 |
20 | {{{body}}} {{{lwr_resources}}}
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/client/modules/services/answer/answer.js:
--------------------------------------------------------------------------------
1 | import { fetchJson } from 'utils/fetch';
2 | import { getCookie } from 'utils/cookies';
3 |
4 | const ANSWERS_REST_URL = '/api/answers';
5 |
6 | const COOKIE_PLAYER_ID = 'playerId';
7 |
8 | /**
9 | * Submits an answer to the current question
10 | * @param {string} answer
11 | * @returns {Promise<*>} Promise holding the Answer record
12 | */
13 | export function submitAnswer(answer) {
14 | const playerId = getCookie(COOKIE_PLAYER_ID);
15 | const answerData = {
16 | playerId,
17 | answer
18 | };
19 | return fetch(ANSWERS_REST_URL, {
20 | method: 'post',
21 | headers: {
22 | 'Content-Type': 'application/json'
23 | },
24 | body: JSON.stringify(answerData)
25 | }).then(fetchJson);
26 | }
27 |
--------------------------------------------------------------------------------
/src/client/modules/services/configuration/configuration.js:
--------------------------------------------------------------------------------
1 | import { register, ValueChangedEvent } from '@lwc/wire-service';
2 | import { fetchJson } from 'utils/fetch';
3 |
4 | export function getConfiguration(config) {
5 | return new Promise((resolve, reject) => {
6 | const observer = {
7 | next: (data) => resolve(data),
8 | error: (error) => reject(error)
9 | };
10 | getData(config, observer);
11 | });
12 | }
13 |
14 | function getData(config, observer) {
15 | fetch('/api/configuration', {
16 | headers: {
17 | pragma: 'no-cache',
18 | 'Cache-Control': 'no-cache'
19 | }
20 | })
21 | .then(fetchJson)
22 | .then((jsonResponse) => {
23 | observer.next(jsonResponse);
24 | })
25 | .catch((error) => {
26 | observer.error(error);
27 | });
28 | }
29 |
30 | register(getConfiguration, (eventTarget) => {
31 | let config;
32 | eventTarget.dispatchEvent(
33 | new ValueChangedEvent({ data: undefined, error: undefined })
34 | );
35 |
36 | const observer = {
37 | next: (data) =>
38 | eventTarget.dispatchEvent(
39 | new ValueChangedEvent({ data, error: undefined })
40 | ),
41 | error: (error) =>
42 | eventTarget.dispatchEvent(
43 | new ValueChangedEvent({ data: undefined, error })
44 | )
45 | };
46 |
47 | eventTarget.addEventListener('config', (newConfig) => {
48 | config = newConfig;
49 | getData(config, observer);
50 | });
51 | /*
52 | // Prevent duplicate initial REST call
53 | eventTarget.addEventListener('connect', () => {
54 | getData(config, observer);
55 | });
56 | */
57 | });
58 |
--------------------------------------------------------------------------------
/src/client/modules/services/player/player.js:
--------------------------------------------------------------------------------
1 | import { register, ValueChangedEvent } from '@lwc/wire-service';
2 | import { fetchJson } from 'utils/fetch';
3 |
4 | const PLAYERS_REST_URL = '/api/players';
5 |
6 | /**
7 | * Checks if a given player nickname is available
8 | * @param {*} config object that contains nickname
9 | */
10 | export function isNicknameAvailable(config) {
11 | return new Promise((resolve, reject) => {
12 | const observer = {
13 | next: (data) => resolve(data),
14 | error: (error) => reject(error)
15 | };
16 | getNicknameData(config, observer);
17 | });
18 | }
19 |
20 | /**
21 | * Gets a player's leaderboard (score and rank)
22 | * @param {*} config
23 | */
24 | export function getPlayerLeaderboard(config) {
25 | return new Promise((resolve, reject) => {
26 | const observer = {
27 | next: (data) => resolve(data),
28 | error: (error) => reject(error)
29 | };
30 | getPlayerLeaderboardData(config, observer);
31 | });
32 | }
33 |
34 | /**
35 | * Gets player's stats
36 | * @param {*} config
37 | */
38 | export function getPlayerStats(config) {
39 | return new Promise((resolve, reject) => {
40 | const observer = {
41 | next: (data) => resolve(data),
42 | error: (error) => reject(error)
43 | };
44 | getPlayerStatsData(config, observer);
45 | });
46 | }
47 |
48 | /**
49 | * Registers a player
50 | * @param {string} nickname
51 | * @param {string} email
52 | * @returns {Promise<*>} Promise holding the Player record
53 | */
54 | export function registerPlayer(nickname, email) {
55 | const userInfo = { nickname, email };
56 | return fetch(PLAYERS_REST_URL, {
57 | method: 'post',
58 | headers: {
59 | Accept: 'application/json',
60 | 'Content-Type': 'application/json'
61 | },
62 | body: JSON.stringify(userInfo)
63 | }).then(fetchJson);
64 | }
65 |
66 | function getNicknameData(config, observer) {
67 | const nickname = config && config.nickname ? config.nickname : null;
68 | if (nickname === null) {
69 | observer.next({ nickname: '', isAvailable: true });
70 | return;
71 | }
72 |
73 | // Call players API to check if nickname is available (cache disabled)
74 | const searchParams = new URLSearchParams({ nickname });
75 | fetch(`${PLAYERS_REST_URL}?${searchParams.toString()}`, {
76 | headers: {
77 | pragma: 'no-cache',
78 | 'Cache-Control': 'no-cache'
79 | }
80 | })
81 | .then(fetchJson)
82 | .then((jsonResponse) => {
83 | observer.next(jsonResponse);
84 | })
85 | .catch((error) => {
86 | observer.error(error);
87 | });
88 | }
89 |
90 | function getPlayerLeaderboardData(config, observer) {
91 | const playerId = config && config.playerId ? config.playerId : null;
92 | if (playerId === null) {
93 | return;
94 | }
95 |
96 | // Call players API to get player's leaderboard (score and rank)
97 | fetch(`${PLAYERS_REST_URL}/${playerId}/leaderboard`, {
98 | headers: {
99 | pragma: 'no-cache',
100 | 'Cache-Control': 'no-cache'
101 | }
102 | })
103 | .then(fetchJson)
104 | .then((jsonResponse) => {
105 | observer.next(jsonResponse);
106 | })
107 | .catch((error) => {
108 | observer.error(error);
109 | });
110 | }
111 |
112 | function getPlayerStatsData(config, observer) {
113 | const playerId = config && config.playerId ? config.playerId : null;
114 | if (playerId === null) {
115 | return;
116 | }
117 |
118 | // Call players API to get player's stats
119 | fetch(`${PLAYERS_REST_URL}/${playerId}/stats`)
120 | .then(fetchJson)
121 | .then((jsonResponse) => {
122 | observer.next(jsonResponse);
123 | })
124 | .catch((error) => {
125 | observer.error(error);
126 | });
127 | }
128 |
129 | register(isNicknameAvailable, (eventTarget) => {
130 | let config;
131 | eventTarget.dispatchEvent(
132 | new ValueChangedEvent({ data: undefined, error: undefined })
133 | );
134 |
135 | const observer = {
136 | next: (data) =>
137 | eventTarget.dispatchEvent(
138 | new ValueChangedEvent({ data, error: undefined })
139 | ),
140 | error: (error) =>
141 | eventTarget.dispatchEvent(
142 | new ValueChangedEvent({ data: undefined, error })
143 | )
144 | };
145 |
146 | eventTarget.addEventListener('config', (newConfig) => {
147 | config = newConfig;
148 | getNicknameData(config, observer);
149 | });
150 |
151 | eventTarget.addEventListener('connect', () => {
152 | getNicknameData(config, observer);
153 | });
154 | });
155 |
156 | register(getPlayerLeaderboard, (eventTarget) => {
157 | let config;
158 | eventTarget.dispatchEvent(
159 | new ValueChangedEvent({ data: undefined, error: undefined })
160 | );
161 |
162 | const observer = {
163 | next: (data) =>
164 | eventTarget.dispatchEvent(
165 | new ValueChangedEvent({ data, error: undefined })
166 | ),
167 | error: (error) =>
168 | eventTarget.dispatchEvent(
169 | new ValueChangedEvent({ data: undefined, error })
170 | )
171 | };
172 |
173 | eventTarget.addEventListener('config', (newConfig) => {
174 | config = newConfig;
175 | getPlayerLeaderboardData(config, observer);
176 | });
177 |
178 | eventTarget.addEventListener('connect', () => {
179 | getPlayerLeaderboardData(config, observer);
180 | });
181 | });
182 |
183 | register(getPlayerStats, (eventTarget) => {
184 | let config;
185 | eventTarget.dispatchEvent(
186 | new ValueChangedEvent({ data: undefined, error: undefined })
187 | );
188 |
189 | const observer = {
190 | next: (data) =>
191 | eventTarget.dispatchEvent(
192 | new ValueChangedEvent({ data, error: undefined })
193 | ),
194 | error: (error) =>
195 | eventTarget.dispatchEvent(
196 | new ValueChangedEvent({ data: undefined, error })
197 | )
198 | };
199 |
200 | eventTarget.addEventListener('config', (newConfig) => {
201 | config = newConfig;
202 | getPlayerStatsData(config, observer);
203 | });
204 |
205 | eventTarget.addEventListener('connect', () => {
206 | getPlayerStatsData(config, observer);
207 | });
208 | });
209 |
--------------------------------------------------------------------------------
/src/client/modules/services/session/session.js:
--------------------------------------------------------------------------------
1 | import { register, ValueChangedEvent } from '@lwc/wire-service';
2 | import { fetchJson } from 'utils/fetch';
3 |
4 | export const PHASES = Object.freeze({
5 | REGISTRATION: 'Registration',
6 | PRE_QUESTION: 'PreQuestion',
7 | QUESTION: 'Question',
8 | QUESTION_RESULTS: 'QuestionResults',
9 | GAME_RESULTS: 'GameResults'
10 | });
11 |
12 | export function getCurrentSession(config) {
13 | return new Promise((resolve, reject) => {
14 | const observer = {
15 | next: (data) => resolve(data),
16 | error: (error) => reject(error)
17 | };
18 | getData(config, observer);
19 | });
20 | }
21 |
22 | function getData(config, observer) {
23 | fetch('/api/quiz-sessions', {
24 | headers: {
25 | pragma: 'no-cache',
26 | 'Cache-Control': 'no-cache'
27 | }
28 | })
29 | .then(fetchJson)
30 | .then((jsonResponse) => {
31 | observer.next(jsonResponse);
32 | })
33 | .catch((error) => {
34 | observer.error(error);
35 | });
36 | }
37 |
38 | register(getCurrentSession, (eventTarget) => {
39 | let config;
40 | eventTarget.dispatchEvent(
41 | new ValueChangedEvent({ data: undefined, error: undefined })
42 | );
43 |
44 | const observer = {
45 | next: (data) =>
46 | eventTarget.dispatchEvent(
47 | new ValueChangedEvent({ data, error: undefined })
48 | ),
49 | error: (error) =>
50 | eventTarget.dispatchEvent(
51 | new ValueChangedEvent({ data: undefined, error })
52 | )
53 | };
54 |
55 | eventTarget.addEventListener('config', (newConfig) => {
56 | config = newConfig;
57 | getData(config, observer);
58 | });
59 | /*
60 | // Prevent duplicate initial REST call
61 | eventTarget.addEventListener('connect', () => {
62 | getData(config, observer);
63 | });
64 | */
65 | });
66 |
--------------------------------------------------------------------------------
/src/client/modules/ui/app/app.css:
--------------------------------------------------------------------------------
1 | :host {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: center;
5 | align-content: stretch;
6 | height: 100%;
7 | }
8 |
9 | .body {
10 | flex-grow: 1;
11 | width: 100%;
12 | display: flex;
13 | flex-direction: column;
14 | }
15 |
16 | h1 {
17 | text-align: center;
18 | }
19 |
20 | .error {
21 | color: red;
22 | }
23 |
24 | span[data-answer] {
25 | margin-left: 0.25rem;
26 | padding: 0 0.5rem;
27 | color: white;
28 | border-radius: 0.3rem;
29 | }
30 | span[data-answer='A'] {
31 | background-color: #1589ee;
32 | }
33 | span[data-answer='B'] {
34 | background-color: #ff9e2c;
35 | }
36 | span[data-answer='C'] {
37 | background-color: #d4504c;
38 | }
39 | span[data-answer='D'] {
40 | background-color: #04844b;
41 | }
42 |
43 | .credits {
44 | text-align: center;
45 | margin-bottom: 1rem;
46 | }
47 | .credits a {
48 | color: #515e7f;
49 | }
50 |
--------------------------------------------------------------------------------
/src/client/modules/ui/app/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
12 |
13 |
14 | Sorry, registrations are closed.
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | Waiting for host to start game...
23 |
24 |
25 |
26 |
27 |
28 | Wait for question...
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
40 |
41 |
42 |
43 |
44 |
45 | You answered
46 | {lastAnswer}
47 |
48 |
49 | Collecting answers from other players...
50 |
51 |
52 | Saving answer...
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 | You answered
64 | {lastAnswer}
65 |
66 |
67 |
68 | Well done, that's correct!
69 |
70 |
71 |
72 | The correct answer was
73 | {session.correctAnswer}
76 |
77 |
78 |
79 |
80 | You didn't answer in time.
81 |
82 |
83 |
84 |
85 | You didn't answer in time.
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 | {errorMessage}
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
115 |
116 |
--------------------------------------------------------------------------------
/src/client/modules/ui/app/app.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-console */
2 | import { LightningElement, wire } from 'lwc';
3 |
4 | import { getErrorMessage } from 'utils/error';
5 | import { getCookie, setCookie, clearCookie } from 'utils/cookies';
6 | import { WebSocketClient } from 'utils/webSocketClient';
7 |
8 | import { PHASES, getCurrentSession } from 'services/session';
9 | import { getPlayerLeaderboard } from 'services/player';
10 | import { submitAnswer } from 'services/answer';
11 |
12 | const COOKIE_PLAYER_NICKNAME = 'nickname';
13 | const COOKIE_PLAYER_ID = 'playerId';
14 | const COOKIE_ANSWER = 'answer';
15 |
16 | export default class App extends LightningElement {
17 | nickname;
18 | session;
19 | errorMessage;
20 | playerLeaderboard = { score: '-', rank: '-' };
21 | showFooter = false;
22 | lastAnswer;
23 | answerSaved;
24 | playerId;
25 | pingTimeout;
26 | ws;
27 |
28 | PLAYER_APP_VERSION = '3.0.2';
29 |
30 | @wire(getCurrentSession)
31 | getCurrentSession({ error, data }) {
32 | if (data) {
33 | this.session = data;
34 | if (!(this.isQuestionPhase || this.isQuestionResultsPhase)) {
35 | clearCookie(COOKIE_ANSWER);
36 | }
37 | } else if (error) {
38 | if (error.status && error.status === 404) {
39 | this.resetGame();
40 | }
41 | this.errorMessage = getErrorMessage(error);
42 | }
43 | }
44 |
45 | connectedCallback() {
46 | this.nickname = getCookie(COOKIE_PLAYER_NICKNAME);
47 | const playerId = getCookie(COOKIE_PLAYER_ID);
48 | if (playerId) {
49 | this.setPlayer(playerId);
50 | }
51 | this.lastAnswer = getCookie(COOKIE_ANSWER);
52 | this.answerSaved = false;
53 |
54 | // Get WebSocket URL
55 | const wsUrl =
56 | (window.location.protocol === 'http:' ? 'ws://' : 'wss://') +
57 | window.location.host +
58 | '/websockets';
59 | // Connect WebSocket
60 | this.ws = new WebSocketClient(wsUrl);
61 | this.ws.connect();
62 | this.ws.addMessageListener((message) => {
63 | this.handleWsMessage(message);
64 | });
65 | }
66 |
67 | disconnectedCallback() {
68 | this.ws.close();
69 | }
70 |
71 | handleWsMessage(message) {
72 | this.errorMessage = undefined;
73 | if (message.type === 'phaseChangeEvent') {
74 | this.session = message.data;
75 | // eslint-disable-next-line default-case
76 | switch (this.session.phase) {
77 | case PHASES.REGISTRATION:
78 | this.resetGame();
79 | break;
80 | case PHASES.QUESTION:
81 | // Clear last answer
82 | clearCookie(COOKIE_ANSWER);
83 | this.lastAnswer = undefined;
84 | this.answerSaved = false;
85 | break;
86 | case PHASES.QUESTION_RESULTS:
87 | // Refresh leaderboard
88 | this.updateLeaderboard();
89 | break;
90 | }
91 | }
92 | }
93 |
94 | handleRegistered(event) {
95 | const { nickname, playerId } = event.detail;
96 |
97 | setCookie(COOKIE_PLAYER_NICKNAME, nickname);
98 | this.nickname = nickname;
99 |
100 | setCookie(COOKIE_PLAYER_ID, playerId);
101 | this.setPlayer(playerId);
102 | }
103 |
104 | handleAnswer(event) {
105 | this.errorMessage = undefined;
106 | const { answer } = event.detail;
107 | setCookie(COOKIE_ANSWER, answer);
108 | this.lastAnswer = answer;
109 | submitAnswer(answer)
110 | .then(() => {
111 | this.answerSaved = true;
112 | })
113 | .catch((error) => {
114 | this.errorMessage = getErrorMessage(error);
115 | });
116 | }
117 |
118 | resetGame() {
119 | clearCookie(COOKIE_PLAYER_NICKNAME);
120 | clearCookie(COOKIE_PLAYER_ID);
121 | clearCookie(COOKIE_ANSWER);
122 | window.location.reload();
123 | }
124 |
125 | setPlayer(playerId) {
126 | this.playerId = playerId;
127 | this.updateLeaderboard();
128 | }
129 |
130 | updateLeaderboard() {
131 | getPlayerLeaderboard({ playerId: this.playerId })
132 | .then((data) => {
133 | this.playerLeaderboard = data;
134 | this.showFooter = true;
135 | })
136 | .catch((error) => {
137 | this.showFooter = false;
138 | if (error.status && error.status === 404) {
139 | this.resetGame();
140 | }
141 | this.errorMessage = getErrorMessage(error);
142 | });
143 | }
144 |
145 | // UI expressions
146 |
147 | get isAuthenticated() {
148 | return this.nickname !== '';
149 | }
150 |
151 | get isRegistrationPhase() {
152 | return this.session.phase === PHASES.REGISTRATION;
153 | }
154 |
155 | get isPreQuestionPhase() {
156 | return this.session.phase === PHASES.PRE_QUESTION;
157 | }
158 |
159 | get isQuestionPhase() {
160 | return this.session.phase === PHASES.QUESTION;
161 | }
162 |
163 | get isQuestionResultsPhase() {
164 | return this.session.phase === PHASES.QUESTION_RESULTS;
165 | }
166 |
167 | get isGameResultsPhase() {
168 | return this.session.phase === PHASES.GAME_RESULTS;
169 | }
170 |
171 | get isCorrectAnswer() {
172 | return (
173 | this.lastAnswer && this.lastAnswer === this.session.correctAnswer
174 | );
175 | }
176 | }
177 |
--------------------------------------------------------------------------------
/src/client/modules/ui/footer/footer.css:
--------------------------------------------------------------------------------
1 | :host {
2 | width: 100%;
3 | }
4 |
5 | footer {
6 | padding: 1rem;
7 | display: flex;
8 | align-items: stretch;
9 | justify-content: space-between;
10 | color: #ffffff;
11 | background: #002169;
12 | box-shadow: 0 -4px 3px rgba(0, 33, 105, 0.5);
13 | }
14 |
15 | div {
16 | font-size: larger;
17 | }
18 |
--------------------------------------------------------------------------------
/src/client/modules/ui/footer/footer.html:
--------------------------------------------------------------------------------
1 |
2 |
6 |
7 |
--------------------------------------------------------------------------------
/src/client/modules/ui/footer/footer.js:
--------------------------------------------------------------------------------
1 | import { LightningElement, api } from 'lwc';
2 |
3 | export default class Footer extends LightningElement {
4 | @api leaderboard;
5 | }
6 |
--------------------------------------------------------------------------------
/src/client/modules/ui/header/header.css:
--------------------------------------------------------------------------------
1 | :host {
2 | width: 100%;
3 | }
4 | header {
5 | padding: 1rem;
6 | display: flex;
7 | align-items: stretch;
8 | justify-content: space-between;
9 | color: #ffffff;
10 | background: #002169;
11 | box-shadow: 0 4px 3px rgba(0, 33, 105, 0.5);
12 | }
13 | header img {
14 | height: 3rem;
15 | margin-right: 1rem;
16 | }
17 | h2 {
18 | font-weight: 300;
19 | margin: auto 0;
20 | }
21 | .app-name {
22 | text-overflow: ellipsis;
23 | }
24 | .nickname {
25 | flex-grow: 1;
26 | text-align: right;
27 | }
28 |
--------------------------------------------------------------------------------
/src/client/modules/ui/header/header.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Quiz
5 | {nickname}
6 |
7 |
8 |
--------------------------------------------------------------------------------
/src/client/modules/ui/header/header.js:
--------------------------------------------------------------------------------
1 | import { LightningElement, api } from 'lwc';
2 |
3 | export default class Header extends LightningElement {
4 | @api nickname;
5 | }
6 |
--------------------------------------------------------------------------------
/src/client/modules/ui/input/input.css:
--------------------------------------------------------------------------------
1 | label {
2 | display: block;
3 | }
4 | input {
5 | border: 1px solid #dddbda;
6 | border-radius: 0.25rem;
7 | width: 100%;
8 | transition:
9 | border 0.1s linear,
10 | background-color 0.1s linear;
11 | display: inline-block;
12 | box-sizing: border-box;
13 | padding: 0 1rem 0 0.75rem;
14 | margin: 0.5rem 0;
15 | line-height: 1.875rem;
16 | min-height: calc(1.875rem + 2px);
17 | }
18 | input[readonly] {
19 | background-color: #ecebea;
20 | }
21 |
22 | .invisible {
23 | visibility: hidden;
24 | }
25 | .icon {
26 | width: 2rem;
27 | height: 2rem;
28 | }
29 |
30 | .has-success input {
31 | border-color: #4bca81;
32 | box-shadow: inset 0 0 0 1px #4bca81;
33 | }
34 | .has-success .icon {
35 | fill: #4bca81;
36 | border-color: #4bca81;
37 | }
38 |
39 | .has-error input {
40 | border-color: #c23934;
41 | box-shadow: inset 0 0 0 1px #c23934;
42 | }
43 | .has-error .icon {
44 | fill: #c23934;
45 | border-color: #c23934;
46 | }
47 |
48 | .error {
49 | color: #c23934;
50 | min-height: 1rem;
51 | }
52 |
53 | .input-with-icons {
54 | display: flex;
55 | align-items: center;
56 | }
57 | .input-with-icons input {
58 | box-sizing: content-box;
59 | }
60 | .input-with-icons .icon {
61 | position: relative;
62 | right: 2rem;
63 | }
64 |
--------------------------------------------------------------------------------
/src/client/modules/ui/input/input.html:
--------------------------------------------------------------------------------
1 |
2 |
21 |
22 |
--------------------------------------------------------------------------------
/src/client/modules/ui/input/input.js:
--------------------------------------------------------------------------------
1 | import { LightningElement, api } from 'lwc';
2 |
3 | export default class Input extends LightningElement {
4 | @api label;
5 | @api autocomplete;
6 | @api readOnly;
7 | @api isValid;
8 | @api maxLength;
9 | @api error;
10 |
11 | @api
12 | set value(newValue) {
13 | this._value = newValue;
14 | }
15 | get value() {
16 | return this._value;
17 | }
18 |
19 | _value = '';
20 | isDirty = false;
21 |
22 | handleChange(event) {
23 | event.preventDefault();
24 | event.stopPropagation();
25 | }
26 |
27 | handleInput(event) {
28 | this.isDirty = true;
29 | this._value = event.target.value;
30 | this.dispatchEvent(
31 | new CustomEvent('change', {
32 | detail: {
33 | value: this._value
34 | }
35 | })
36 | );
37 | }
38 |
39 | get formElementClass() {
40 | if (this.isDirty) {
41 | if (this.isValid) {
42 | return 'has-success';
43 | }
44 | return 'has-error';
45 | }
46 | return '';
47 | }
48 |
49 | get validationIconHref() {
50 | return `/assets/slds-icons-action.svg${
51 | this.isValid ? '#approval' : '#close'
52 | }`;
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/client/modules/ui/playerStats/playerStats.css:
--------------------------------------------------------------------------------
1 | :host {
2 | font-size: 1.5rem;
3 | }
4 | .container {
5 | margin: auto;
6 | width: fit-content;
7 | }
8 | .centered {
9 | text-align: center;
10 | }
11 | .playerName,
12 | .large {
13 | font-size: 2rem;
14 | }
15 | .playerName {
16 | color: #ffffff;
17 | background: #ea7600;
18 | padding: 0.25rem;
19 | border-radius: 0.25rem;
20 | }
21 |
--------------------------------------------------------------------------------
/src/client/modules/ui/playerStats/playerStats.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | {error}
4 |
5 |
6 |
7 |
8 |
9 |
10 | {playerStats.name}
11 |
12 |
13 | Ranked #{playerStats.rank}
14 |
15 |
16 | with
17 | {playerStats.score} points.
18 |
19 |
20 | ✔ {playerStats.correctCount} correct
22 |
23 |
24 | ✖ {playerStats.wrongCount}
25 | incorrect
26 |
27 |
28 |
29 |
30 |
31 |
--------------------------------------------------------------------------------
/src/client/modules/ui/playerStats/playerStats.js:
--------------------------------------------------------------------------------
1 | import { LightningElement, api, wire } from 'lwc';
2 | import { getPlayerStats } from 'services/player';
3 | import { getErrorMessage } from 'utils/error';
4 |
5 | export default class Winner extends LightningElement {
6 | @api playerId;
7 | playerStats;
8 | error;
9 |
10 | @wire(getPlayerStats, { playerId: '$playerId' })
11 | wiredPlayer({ error, data }) {
12 | if (data) {
13 | this.playerStats = data;
14 | this.error = undefined;
15 | } else if (error) {
16 | this.error = getErrorMessage(error);
17 | this.playerStats = undefined;
18 | }
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/client/modules/ui/question/question.css:
--------------------------------------------------------------------------------
1 | :host {
2 | width: 100%;
3 | display: flex;
4 | flex-wrap: wrap;
5 | flex-grow: 1;
6 | flex-direction: column;
7 | }
8 |
9 | .question-label {
10 | font-size: 1.5rem;
11 | margin: 1rem 1rem 0;
12 | }
13 |
14 | .answers {
15 | display: flex;
16 | flex-wrap: wrap;
17 | flex-grow: 1;
18 | }
19 |
20 | .answer {
21 | width: 50%;
22 | display: flex;
23 | }
24 |
25 | .answer button {
26 | width: 100%;
27 | border-radius: 0.5rem;
28 | border-width: 0;
29 | color: #ffffff;
30 | margin: 1rem;
31 | }
32 |
33 | .answer button[data-answer='A'] {
34 | background-color: #1589ee;
35 | }
36 |
37 | .answer button[data-answer='B'] {
38 | background-color: #ff9e2c;
39 | }
40 |
41 | .answer button[data-answer='C'] {
42 | background-color: #d4504c;
43 | }
44 |
45 | .answer button[data-answer='D'] {
46 | background-color: #04844b;
47 | }
48 |
49 | .answer .letter {
50 | font-size: xx-large;
51 | font-weight: bold;
52 | margin-bottom: 0.5rem;
53 | pointer-events: none;
54 | }
55 | .answer .label {
56 | font-size: large;
57 | font-weight: bold;
58 | pointer-events: none;
59 | }
60 |
--------------------------------------------------------------------------------
/src/client/modules/ui/question/question.html:
--------------------------------------------------------------------------------
1 |
2 | {question.label}
3 |
4 |
5 |
6 | A
7 | {question.answerA}
8 |
9 |
10 |
11 |
12 | B
13 | {question.answerB}
14 |
15 |
16 |
17 |
18 | C
19 | {question.answerC}
20 |
21 |
22 |
23 |
24 | D
25 | {question.answerD}
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/client/modules/ui/question/question.js:
--------------------------------------------------------------------------------
1 | import { LightningElement, api } from 'lwc';
2 |
3 | export default class Question extends LightningElement {
4 | @api question;
5 | isSaving;
6 |
7 | connectedCallback() {
8 | this.isSaving = false;
9 | }
10 |
11 | handleAnswerClick(event) {
12 | // Prevent duplicate answers
13 | if (this.isSaving) {
14 | return;
15 | }
16 | this.isSaving = true;
17 | // Send answer to parent component
18 | const { answer } = event.target.dataset;
19 | const answerEvent = new CustomEvent('answer', {
20 | detail: {
21 | answer
22 | }
23 | });
24 | this.dispatchEvent(answerEvent);
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/src/client/modules/ui/registrationForm/registrationForm.css:
--------------------------------------------------------------------------------
1 | :host {
2 | display: flex;
3 | margin: 1rem auto;
4 | flex-direction: column;
5 | align-content: stretch;
6 | width: 80%;
7 | max-width: 400px;
8 | }
9 |
10 | button {
11 | padding: 0 1rem;
12 | border-radius: 0.25rem;
13 | line-height: 1.875rem;
14 | user-select: none;
15 | cursor: pointer;
16 | text-align: center;
17 | vertical-align: middle;
18 | transition: border 0.15s linear;
19 | background-color: #2e5266;
20 | color: #fff;
21 | min-width: 8rem;
22 | }
23 | button:hover {
24 | background-color: #416173;
25 | }
26 | button[disabled] {
27 | background: #c9c7c5;
28 | cursor: default;
29 | }
30 |
31 | .form-footer {
32 | display: flex;
33 | justify-content: center;
34 | margin-top: 1.5rem;
35 | }
36 |
--------------------------------------------------------------------------------
/src/client/modules/ui/registrationForm/registrationForm.html:
--------------------------------------------------------------------------------
1 |
2 | {formError}
3 |
36 |
37 |
--------------------------------------------------------------------------------
/src/client/modules/ui/registrationForm/registrationForm.js:
--------------------------------------------------------------------------------
1 | import { LightningElement, wire } from 'lwc';
2 | import { getErrorMessage } from 'utils/error';
3 |
4 | import { getConfiguration } from 'services/configuration';
5 | import { isNicknameAvailable, registerPlayer } from 'services/player';
6 |
7 | const VALIDATION_DELAY = 500;
8 |
9 | export default class RegistrationForm extends LightningElement {
10 | configuration;
11 |
12 | nickname = '';
13 | cleanNickname;
14 | isNicknameValid;
15 | nicknameError;
16 |
17 | email = '';
18 | isEmailValid;
19 | emailError;
20 |
21 | isLoading = false;
22 | isRegistering = false;
23 | formError = '';
24 |
25 | validationDelayTimeout;
26 |
27 | @wire(getConfiguration)
28 | getConfiguration({ error, data }) {
29 | if (data) {
30 | this.configuration = data;
31 | } else if (error) {
32 | this.formError = getErrorMessage(error);
33 | }
34 | }
35 |
36 | @wire(isNicknameAvailable, { nickname: '$cleanNickname' })
37 | isNicknameAvailable({ error, data }) {
38 | if (data) {
39 | const { nickname, isAvailable } = data;
40 | this.isLoading = false;
41 | this.isNicknameValid = isAvailable;
42 | if (!isAvailable) {
43 | this.nicknameError = `Nickname '${nickname}' is already in use.`;
44 | }
45 | } else if (error) {
46 | this.isLoading = false;
47 | this.isNicknameValid = false;
48 | this.nicknameError = getErrorMessage(error);
49 | }
50 | }
51 |
52 | handleNicknameChange(event) {
53 | clearTimeout(this.validationDelayTimeout);
54 | this.isLoading = false;
55 | this.nicknameError = null;
56 |
57 | this.nickname = event.detail.value;
58 | const cleanNickname = this.nickname.trim().toLowerCase();
59 |
60 | // Don't validate blank nicknames
61 | if (cleanNickname === '') {
62 | this.isNicknameValid = false;
63 | return;
64 | }
65 | // Don't validate if clean nickname did not change
66 | if (this.cleanNickname === cleanNickname) {
67 | return;
68 | }
69 |
70 | this.isLoading = true;
71 | // eslint-disable-next-line @lwc/lwc/no-async-operation
72 | this.validationDelayTimeout = setTimeout(() => {
73 | this.cleanNickname = cleanNickname;
74 | }, VALIDATION_DELAY);
75 | }
76 |
77 | handleEmailChange(event) {
78 | this.email = event.detail.value;
79 | this.emailError = null;
80 | if (this.email.trim() === '') {
81 | this.isEmailValid = false;
82 | this.emailError = 'Email is required';
83 | } else {
84 | this.isEmailValid = new RegExp(
85 | /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/,
86 | 'i'
87 | ).test(this.email);
88 | if (this.isEmailValid === false) {
89 | this.emailError = 'Invalid email format';
90 | }
91 | }
92 | }
93 |
94 | handleSubmit(event) {
95 | event.preventDefault();
96 | event.stopPropagation();
97 | if (this.isRegistrationDisabled) {
98 | return;
99 | }
100 |
101 | this.isLoading = true;
102 | this.isRegistering = true;
103 | const nickname = this.nickname.trim();
104 | registerPlayer(nickname, this.email)
105 | .then((result) => {
106 | this.dispatchEvent(
107 | new CustomEvent('registered', {
108 | detail: {
109 | nickname,
110 | playerId: result.id
111 | }
112 | })
113 | );
114 | })
115 | .catch((error) => {
116 | this.isLoading = false;
117 | this.isRegistering = false;
118 | this.isNicknameValid = false;
119 | this.formError = getErrorMessage(error);
120 | });
121 | }
122 |
123 | // UI expressions
124 |
125 | get isRegistrationDisabled() {
126 | return (
127 | this.nickname.trim() === '' ||
128 | !this.isNicknameValid ||
129 | (this.shouldCollectPlayerEmails && !this.isEmailValid) ||
130 | this.isLoading
131 | );
132 | }
133 |
134 | get shouldCollectPlayerEmails() {
135 | return (
136 | this.configuration && this.configuration.shouldCollectPlayerEmails
137 | );
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/src/client/modules/ui/spinner/spinner.css:
--------------------------------------------------------------------------------
1 | :host {
2 | display: block;
3 | text-align: center;
4 | }
5 | .spinner {
6 | display: inline-block;
7 | width: 3rem;
8 | height: 3rem;
9 | }
10 | .spinner:after {
11 | content: ' ';
12 | display: block;
13 | width: 2rem;
14 | height: 2rem;
15 | border-radius: 50%;
16 | border: 5px solid #20445e;
17 | border-color: #20445e transparent #20445e transparent;
18 | animation: spinner 1.2s linear infinite;
19 | }
20 | @keyframes spinner {
21 | 0% {
22 | transform: rotate(0deg);
23 | }
24 | 100% {
25 | transform: rotate(360deg);
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/client/modules/ui/spinner/spinner.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/src/client/modules/ui/spinner/spinner.js:
--------------------------------------------------------------------------------
1 | import { LightningElement } from 'lwc';
2 |
3 | export default class Spinner extends LightningElement {}
4 |
--------------------------------------------------------------------------------
/src/client/modules/utils/cookies/cookies.js:
--------------------------------------------------------------------------------
1 | const COOKIE_DURATION = 3600000; // 1 hour
2 |
3 | /**
4 | * Create a cookie for the given name, value, and expiration in days.
5 | * @param {String} cname The name of the cookie to set.
6 | * @param {String} cvalue The value of the cookie to set.
7 | */
8 | const setCookie = (cname, cvalue) => {
9 | const d = new Date();
10 | d.setTime(d.getTime() + COOKIE_DURATION);
11 | document.cookie = `${cname}=${escape(
12 | cvalue
13 | )}; expires=${d.toUTCString()}; path=/`;
14 | };
15 |
16 | /**
17 | * Gets a cookie with the given name
18 | * @param {String} cname The name of the cookie to retrieve.
19 | * @returns {String} cookie value or an empty string if cookie is not found
20 | */
21 | const getCookie = (cname) => {
22 | const name = `${cname}=`;
23 | const ca = document.cookie.split(';');
24 | for (let i = 0; i < ca.length; i++) {
25 | let c = ca[i];
26 | while (c.charAt(0) === ' ') {
27 | c = c.substring(1);
28 | }
29 | if (c.indexOf(name) === 0) {
30 | return unescape(c.substring(name.length, c.length));
31 | }
32 | }
33 | return '';
34 | };
35 |
36 | /**
37 | * Clear cookie
38 | * @param {String} cname The name of the cookie to retrieve.
39 | */
40 | const clearCookie = (cname) => {
41 | const pathBits = window.location.pathname.split('/');
42 | let pathCurrent = ' path=';
43 | document.cookie = cname + '=; expires=Thu, 01-Jan-1970 00:00:01 GMT;';
44 | pathBits.forEach((pathBit) => {
45 | pathCurrent += (pathCurrent.substr(-1) !== '/' ? '/' : '') + pathBit;
46 | document.cookie =
47 | cname +
48 | '=; expires=Thu, 01-Jan-1970 00:00:01 GMT;' +
49 | pathCurrent +
50 | ';';
51 | });
52 | };
53 |
54 | export { setCookie, getCookie, clearCookie };
55 |
--------------------------------------------------------------------------------
/src/client/modules/utils/error/error.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Extract a single error message from an error array
3 | * @param {Array|Object} errors single error or array of errors
4 | */
5 | const getErrorMessage = (errors) => {
6 | if (!Array.isArray(errors)) {
7 | errors = [errors];
8 | }
9 |
10 | return (
11 | errors
12 | .filter((error) => !!error)
13 | // Extract an error message
14 | .map((error) => {
15 | // UI API read errors
16 | if (Array.isArray(error.body)) {
17 | return error.body.map((e) => e.message);
18 | }
19 | // UI API DML, Apex and network errors
20 | else if (error.body && typeof error.body.message === 'string') {
21 | return error.body.message;
22 | }
23 | // JS errors
24 | else if (typeof error.message === 'string') {
25 | return error.message;
26 | }
27 | // Unknown error shape so try HTTP status text
28 | return error.statusText;
29 | })
30 | // Flatten
31 | .reduce((prev, curr) => prev.concat(curr), [])
32 | // Remove empty strings
33 | .filter((message) => !!message)
34 | );
35 | };
36 |
37 | export { getErrorMessage };
38 |
--------------------------------------------------------------------------------
/src/client/modules/utils/fetch/fetch.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Handles Fetch API JSON responses
3 | * @param {*} response
4 | * @returns {Promise<*>} Promise holding JSON parsed data or null if response holds no JSON
5 | */
6 | const fetchJson = (response) => {
7 | return new Promise((resolve, reject) => {
8 | if (
9 | response.headers.get('Content-Type') ===
10 | 'application/json; charset=utf-8'
11 | ) {
12 | response.json().then((json) => {
13 | if (!response.ok) {
14 | if (!json) {
15 | json = {};
16 | }
17 | json.status = response.status;
18 | reject(json);
19 | } else {
20 | resolve(json);
21 | }
22 | });
23 | } else {
24 | // Safely handle non JSON responses
25 | if (!response.ok) {
26 | reject(null);
27 | } else {
28 | resolve(null);
29 | }
30 | }
31 | });
32 | };
33 |
34 | export { fetchJson };
35 |
--------------------------------------------------------------------------------
/src/client/modules/utils/webSocketClient/webSocketClient.js:
--------------------------------------------------------------------------------
1 | export class WebSocketClient {
2 | constructor(url) {
3 | this.url = url;
4 | this.messageListeners = [];
5 | }
6 |
7 | connect() {
8 | // Open connection
9 | console.log('WS opening ', this.url);
10 | this.ws = new WebSocket(this.url);
11 | this.ws.addEventListener('open', () => {
12 | console.log('WS open');
13 | this.heartbeat();
14 | });
15 |
16 | // Listen for messages while filtering ping messages
17 | this.ws.addEventListener('message', (event) => {
18 | const eventData = JSON.parse(event.data);
19 | if (eventData.type === 'ping') {
20 | this.ws.send('{ "type" : "pong" }');
21 | this.heartbeat();
22 | } else {
23 | this.messageListeners.forEach((listener) => {
24 | listener(eventData);
25 | });
26 | }
27 | });
28 |
29 | // Listen for errors
30 | this.ws.addEventListener('error', (event) => {
31 | console.error('WS error', event);
32 | });
33 |
34 | this.ws.addEventListener('close', () => {
35 | clearTimeout(this.pingTimeout);
36 | console.info('WS connection closed');
37 | });
38 | }
39 |
40 | addMessageListener(listener) {
41 | this.messageListeners.push(listener);
42 | }
43 |
44 | heartbeat() {
45 | clearTimeout(this.pingTimeout);
46 | // eslint-disable-next-line @lwc/lwc/no-async-operation
47 | this.pingTimeout = setTimeout(() => {
48 | this.ws.close();
49 | console.warn('WS connection closed after timeout. Reconnecting.');
50 | this.connect();
51 | }, 30000 + 1000);
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/src/client/resources/dist/normalize.css:
--------------------------------------------------------------------------------
1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
2 |
3 | /* Document
4 | ========================================================================== */
5 |
6 | /**
7 | * 1. Correct the line height in all browsers.
8 | * 2. Prevent adjustments of font size after orientation changes in iOS.
9 | */
10 |
11 | html {
12 | line-height: 1.15; /* 1 */
13 | -webkit-text-size-adjust: 100%; /* 2 */
14 | }
15 |
16 | /* Sections
17 | ========================================================================== */
18 |
19 | /**
20 | * Remove the margin in all browsers.
21 | */
22 |
23 | body {
24 | margin: 0;
25 | }
26 |
27 | /**
28 | * Render the `main` element consistently in IE.
29 | */
30 |
31 | main {
32 | display: block;
33 | }
34 |
35 | /**
36 | * Correct the font size and margin on `h1` elements within `section` and
37 | * `article` contexts in Chrome, Firefox, and Safari.
38 | */
39 |
40 | h1 {
41 | font-size: 2em;
42 | margin: 0.67em 0;
43 | }
44 |
45 | /* Grouping content
46 | ========================================================================== */
47 |
48 | /**
49 | * 1. Add the correct box sizing in Firefox.
50 | * 2. Show the overflow in Edge and IE.
51 | */
52 |
53 | hr {
54 | box-sizing: content-box; /* 1 */
55 | height: 0; /* 1 */
56 | overflow: visible; /* 2 */
57 | }
58 |
59 | /**
60 | * 1. Correct the inheritance and scaling of font size in all browsers.
61 | * 2. Correct the odd `em` font sizing in all browsers.
62 | */
63 |
64 | pre {
65 | font-family: monospace, monospace; /* 1 */
66 | font-size: 1em; /* 2 */
67 | }
68 |
69 | /* Text-level semantics
70 | ========================================================================== */
71 |
72 | /**
73 | * Remove the gray background on active links in IE 10.
74 | */
75 |
76 | a {
77 | background-color: transparent;
78 | }
79 |
80 | /**
81 | * 1. Remove the bottom border in Chrome 57-
82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
83 | */
84 |
85 | abbr[title] {
86 | border-bottom: none; /* 1 */
87 | text-decoration: underline; /* 2 */
88 | text-decoration: underline dotted; /* 2 */
89 | }
90 |
91 | /**
92 | * Add the correct font weight in Chrome, Edge, and Safari.
93 | */
94 |
95 | b,
96 | strong {
97 | font-weight: bolder;
98 | }
99 |
100 | /**
101 | * 1. Correct the inheritance and scaling of font size in all browsers.
102 | * 2. Correct the odd `em` font sizing in all browsers.
103 | */
104 |
105 | code,
106 | kbd,
107 | samp {
108 | font-family: monospace, monospace; /* 1 */
109 | font-size: 1em; /* 2 */
110 | }
111 |
112 | /**
113 | * Add the correct font size in all browsers.
114 | */
115 |
116 | small {
117 | font-size: 80%;
118 | }
119 |
120 | /**
121 | * Prevent `sub` and `sup` elements from affecting the line height in
122 | * all browsers.
123 | */
124 |
125 | sub,
126 | sup {
127 | font-size: 75%;
128 | line-height: 0;
129 | position: relative;
130 | vertical-align: baseline;
131 | }
132 |
133 | sub {
134 | bottom: -0.25em;
135 | }
136 |
137 | sup {
138 | top: -0.5em;
139 | }
140 |
141 | /* Embedded content
142 | ========================================================================== */
143 |
144 | /**
145 | * Remove the border on images inside links in IE 10.
146 | */
147 |
148 | img {
149 | border-style: none;
150 | }
151 |
152 | /* Forms
153 | ========================================================================== */
154 |
155 | /**
156 | * 1. Change the font styles in all browsers.
157 | * 2. Remove the margin in Firefox and Safari.
158 | */
159 |
160 | button,
161 | input,
162 | optgroup,
163 | select,
164 | textarea {
165 | font-family: inherit; /* 1 */
166 | font-size: 100%; /* 1 */
167 | line-height: 1.15; /* 1 */
168 | margin: 0; /* 2 */
169 | }
170 |
171 | /**
172 | * Show the overflow in IE.
173 | * 1. Show the overflow in Edge.
174 | */
175 |
176 | button,
177 | input { /* 1 */
178 | overflow: visible;
179 | }
180 |
181 | /**
182 | * Remove the inheritance of text transform in Edge, Firefox, and IE.
183 | * 1. Remove the inheritance of text transform in Firefox.
184 | */
185 |
186 | button,
187 | select { /* 1 */
188 | text-transform: none;
189 | }
190 |
191 | /**
192 | * Correct the inability to style clickable types in iOS and Safari.
193 | */
194 |
195 | button,
196 | [type="button"],
197 | [type="reset"],
198 | [type="submit"] {
199 | -webkit-appearance: button;
200 | }
201 |
202 | /**
203 | * Remove the inner border and padding in Firefox.
204 | */
205 |
206 | button::-moz-focus-inner,
207 | [type="button"]::-moz-focus-inner,
208 | [type="reset"]::-moz-focus-inner,
209 | [type="submit"]::-moz-focus-inner {
210 | border-style: none;
211 | padding: 0;
212 | }
213 |
214 | /**
215 | * Restore the focus styles unset by the previous rule.
216 | */
217 |
218 | button:-moz-focusring,
219 | [type="button"]:-moz-focusring,
220 | [type="reset"]:-moz-focusring,
221 | [type="submit"]:-moz-focusring {
222 | outline: 1px dotted ButtonText;
223 | }
224 |
225 | /**
226 | * Correct the padding in Firefox.
227 | */
228 |
229 | fieldset {
230 | padding: 0.35em 0.75em 0.625em;
231 | }
232 |
233 | /**
234 | * 1. Correct the text wrapping in Edge and IE.
235 | * 2. Correct the color inheritance from `fieldset` elements in IE.
236 | * 3. Remove the padding so developers are not caught out when they zero out
237 | * `fieldset` elements in all browsers.
238 | */
239 |
240 | legend {
241 | box-sizing: border-box; /* 1 */
242 | color: inherit; /* 2 */
243 | display: table; /* 1 */
244 | max-width: 100%; /* 1 */
245 | padding: 0; /* 3 */
246 | white-space: normal; /* 1 */
247 | }
248 |
249 | /**
250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera.
251 | */
252 |
253 | progress {
254 | vertical-align: baseline;
255 | }
256 |
257 | /**
258 | * Remove the default vertical scrollbar in IE 10+.
259 | */
260 |
261 | textarea {
262 | overflow: auto;
263 | }
264 |
265 | /**
266 | * 1. Add the correct box sizing in IE 10.
267 | * 2. Remove the padding in IE 10.
268 | */
269 |
270 | [type="checkbox"],
271 | [type="radio"] {
272 | box-sizing: border-box; /* 1 */
273 | padding: 0; /* 2 */
274 | }
275 |
276 | /**
277 | * Correct the cursor style of increment and decrement buttons in Chrome.
278 | */
279 |
280 | [type="number"]::-webkit-inner-spin-button,
281 | [type="number"]::-webkit-outer-spin-button {
282 | height: auto;
283 | }
284 |
285 | /**
286 | * 1. Correct the odd appearance in Chrome and Safari.
287 | * 2. Correct the outline style in Safari.
288 | */
289 |
290 | [type="search"] {
291 | -webkit-appearance: textfield; /* 1 */
292 | outline-offset: -2px; /* 2 */
293 | }
294 |
295 | /**
296 | * Remove the inner padding in Chrome and Safari on macOS.
297 | */
298 |
299 | [type="search"]::-webkit-search-decoration {
300 | -webkit-appearance: none;
301 | }
302 |
303 | /**
304 | * 1. Correct the inability to style clickable types in iOS and Safari.
305 | * 2. Change font properties to `inherit` in Safari.
306 | */
307 |
308 | ::-webkit-file-upload-button {
309 | -webkit-appearance: button; /* 1 */
310 | font: inherit; /* 2 */
311 | }
312 |
313 | /* Interactive
314 | ========================================================================== */
315 |
316 | /*
317 | * Add the correct display in Edge, IE 10+, and Firefox.
318 | */
319 |
320 | details {
321 | display: block;
322 | }
323 |
324 | /*
325 | * Add the correct display in all browsers.
326 | */
327 |
328 | summary {
329 | display: list-item;
330 | }
331 |
332 | /* Misc
333 | ========================================================================== */
334 |
335 | /**
336 | * Add the correct display in IE 10+.
337 | */
338 |
339 | template {
340 | display: none;
341 | }
342 |
343 | /**
344 | * Add the correct display in IE 10.
345 | */
346 |
347 | [hidden] {
348 | display: none;
349 | }
350 |
--------------------------------------------------------------------------------
/src/server/rest/answer.js:
--------------------------------------------------------------------------------
1 | const Configuration = require('../utils/configuration.js');
2 |
3 | module.exports = class AnswerRestResource {
4 | constructor(sfdc) {
5 | this.sfdc = sfdc;
6 | }
7 |
8 | submitAnswer(request, response) {
9 | const { playerId, answer } = request.body;
10 | if (!(playerId && answer)) {
11 | response.status(400).json({ message: 'Missing parameter.' });
12 | return;
13 | }
14 |
15 | const ns = Configuration.getSfNamespacePath();
16 | this.sfdc.apex.post(`${ns}/quiz/answers`, request.body, (error) => {
17 | if (error) {
18 | response.status(500).json({ message: error.message });
19 | } else {
20 | response.sendStatus(200);
21 | }
22 | });
23 | }
24 | };
25 |
--------------------------------------------------------------------------------
/src/server/rest/configuration.js:
--------------------------------------------------------------------------------
1 | const Configuration = require('../utils/configuration.js');
2 |
3 | module.exports = class ConfigurationRestResource {
4 | /**
5 | * Gets the app configuration
6 | * @returns Object holding the app configuration
7 | */
8 | getConfiguration(request, response) {
9 | const config = {
10 | shouldCollectPlayerEmails: Configuration.shouldCollectPlayerEmails()
11 | };
12 | response.json(config);
13 | }
14 | };
15 |
--------------------------------------------------------------------------------
/src/server/rest/player.js:
--------------------------------------------------------------------------------
1 | const Configuration = require('../utils/configuration.js');
2 |
3 | module.exports = class PlayerRestResource {
4 | constructor(sfdc) {
5 | this.sfdc = sfdc;
6 | }
7 |
8 | isNicknameAvailable(request, response) {
9 | const { nickname } = request.query;
10 | if (!nickname) {
11 | response
12 | .status(400)
13 | .json({ message: 'Missing nickname parameter.' });
14 | return;
15 | }
16 |
17 | const ns = Configuration.getSfNamespacePrefix();
18 | const soql = `SELECT Id FROM ${ns}Quiz_Player__c WHERE Name='${nickname.replace(
19 | "'",
20 | "\\'"
21 | )}'`;
22 | this.sfdc.query(soql, (error, result) => {
23 | if (error) {
24 | console.error('isNicknameAvailable', error);
25 | response.sendStatus(500);
26 | } else {
27 | response.json({
28 | nickname,
29 | isAvailable: result.records.length === 0
30 | });
31 | }
32 | });
33 | }
34 |
35 | registerPlayer(request, response) {
36 | const { nickname, email } = request.body;
37 | if (!nickname) {
38 | response
39 | .status(400)
40 | .json({ message: 'Missing nickname parameter.' });
41 | return;
42 | }
43 |
44 | const ns = Configuration.getSfNamespacePrefix();
45 | const playerRecord = { Name: nickname };
46 | playerRecord[`${ns}Email__c`] = email;
47 |
48 | this.sfdc
49 | .sobject(`${ns}Quiz_Player__c`)
50 | .insert(playerRecord, (error, result) => {
51 | if (error || !result.success) {
52 | if (
53 | error.errorCode &&
54 | error.fields &&
55 | error.errorCode ===
56 | 'FIELD_CUSTOM_VALIDATION_EXCEPTION' &&
57 | error.fields.includes('Name')
58 | ) {
59 | response.status(409).json({
60 | message: `Nickname '${nickname}' is already in use.`
61 | });
62 | } else {
63 | console.error('registerPlayer ', error);
64 | response
65 | .status(500)
66 | .json({ message: 'Failed to register player.' });
67 | }
68 | } else {
69 | response.json(result);
70 | }
71 | });
72 | }
73 |
74 | getPlayerLeaderboard(request, response) {
75 | const { playerId } = request.params;
76 | if (!playerId) {
77 | response
78 | .status(400)
79 | .json({ message: 'Missing playerId parameter.' });
80 | return;
81 | }
82 |
83 | const ns = Configuration.getSfNamespacePrefix();
84 | const soql = `SELECT ${ns}Score__c, ${ns}Ranking__c FROM ${ns}Quiz_Player__c WHERE Id='${playerId}'`;
85 | this.sfdc.query(soql, (error, result) => {
86 | if (error) {
87 | console.error('getPlayerLeaderboard', error);
88 | response.sendStatus(500);
89 | } else if (result.records.length === 0) {
90 | response.status(404).json({ message: 'Unkown player.' });
91 | } else {
92 | const record = result.records[0];
93 | const leaderboard = {
94 | score: record[`${ns}Score__c`],
95 | rank: record[`${ns}Ranking__c`]
96 | };
97 | response.json(leaderboard);
98 | }
99 | });
100 | }
101 |
102 | getPlayerStats(request, response) {
103 | const { playerId } = request.params;
104 | if (!playerId) {
105 | response
106 | .status(400)
107 | .json({ message: 'Missing playerId parameter.' });
108 | return;
109 | }
110 |
111 | const ns = Configuration.getSfNamespacePath();
112 | this.sfdc.apex.get(
113 | `${ns}/quiz/player/stats?id=${playerId}`,
114 | (error, result) => {
115 | if (error) {
116 | response.status(500).json({ message: error.message });
117 | } else {
118 | response.send(result);
119 | }
120 | }
121 | );
122 | }
123 | };
124 |
--------------------------------------------------------------------------------
/src/server/rest/quiz-session.js:
--------------------------------------------------------------------------------
1 | const Configuration = require('../utils/configuration.js');
2 |
3 | module.exports = class QuizSessionRestResource {
4 | /**
5 | * @param {*} sfdc Salesforce client
6 | * @param {*} wss WebSocket server
7 | */
8 | constructor(sfdc, wss) {
9 | this.sfdc = sfdc;
10 | this.wss = wss;
11 | }
12 |
13 | /**
14 | * Get current quiz session
15 | * @param {*} request
16 | * @param {*} response
17 | */
18 | getSession(request, response) {
19 | const ns = Configuration.getSfNamespacePrefix();
20 | const soql = `SELECT ${ns}Phase__c FROM ${ns}Quiz_Session__c`;
21 | this.sfdc.query(soql, (error, result) => {
22 | if (error) {
23 | console.error('getSession', error);
24 | response.status(500).json(error);
25 | } else if (result.records.length !== 1) {
26 | const message = 'Could not retrieve Quiz Session record.';
27 | console.error('getSession', message);
28 | response.status(404).json({ message });
29 | } else {
30 | const record = result.records[0];
31 | const phase = record[`${ns}Phase__c`];
32 | response.json({ phase });
33 | }
34 | });
35 | }
36 |
37 | /**
38 | * Updates current quiz session
39 | * @param {*} request
40 | * @param {*} response
41 | */
42 | updateSession(request, response) {
43 | // Check API key header
44 | const apiKey = request.get('Api-Key');
45 | if (!apiKey) {
46 | response.status(400).json({ message: 'Missing Quiz API Key.' });
47 | return;
48 | }
49 | if (apiKey !== Configuration.getQuizApiKey()) {
50 | response.status(403).json({ message: 'Invalid Quiz API Key.' });
51 | return;
52 | }
53 | // Check parameters
54 | const { phase } = request.body;
55 | if (!phase) {
56 | response
57 | .status(400)
58 | .json({ message: 'Missing Phase__c parameter.' });
59 | return;
60 | }
61 | // Broadcast phase change via WSS
62 | const phaseChangeEvent = {
63 | type: 'phaseChangeEvent',
64 | data: {
65 | phase
66 | }
67 | };
68 |
69 | // Get question label when phase is Question
70 | if (phase === 'Question') {
71 | this.getQuestion()
72 | .then((question) => {
73 | phaseChangeEvent.data.question = question;
74 | this.wss.broadcast(phaseChangeEvent);
75 | response.sendStatus(200);
76 | })
77 | .catch((error) => {
78 | console.error('getQuestion', error);
79 | response.status(500).json(error);
80 | });
81 | }
82 | // Send correct answer when phase is QuestionResults
83 | else if (phase === 'QuestionResults') {
84 | this.getCorrectAnwer()
85 | .then((correctAnswer) => {
86 | phaseChangeEvent.data.correctAnswer = correctAnswer;
87 | this.wss.broadcast(phaseChangeEvent);
88 | response.sendStatus(200);
89 | })
90 | .catch((error) => {
91 | console.error('getCorrectAnwer', error);
92 | response.status(500).json(error);
93 | });
94 | } else {
95 | this.wss.broadcast(phaseChangeEvent);
96 | response.sendStatus(200);
97 | }
98 | }
99 |
100 | /**
101 | * Gets the correct answer to the current question
102 | * @returns {Promise} Promise holding the correct answer
103 | */
104 | getCorrectAnwer() {
105 | return new Promise((resolve, reject) => {
106 | const ns = Configuration.getSfNamespacePrefix();
107 | const soql = `SELECT ${ns}Current_Question__r.${ns}Correct_Answer__c FROM ${ns}Quiz_Session__c`;
108 | this.sfdc.query(soql, (error, result) => {
109 | if (error) {
110 | reject(error);
111 | } else if (result.records.length !== 1) {
112 | reject({
113 | message: 'Could not retrieve Quiz Session record.'
114 | });
115 | } else {
116 | resolve(
117 | result.records[0][`${ns}Current_Question__r`][
118 | `${ns}Correct_Answer__c`
119 | ]
120 | );
121 | }
122 | });
123 | });
124 | }
125 |
126 | /**
127 | * Gets the current question's label
128 | * @returns {Promise} Promise holding the question label
129 | */
130 | getQuestion() {
131 | return new Promise((resolve, reject) => {
132 | const ns = Configuration.getSfNamespacePrefix();
133 | const soql = `SELECT ${ns}Current_Question__r.${ns}Label__c,
134 | ${ns}Current_Question__r.${ns}Answer_A__c,
135 | ${ns}Current_Question__r.${ns}Answer_B__c,
136 | ${ns}Current_Question__r.${ns}Answer_C__c,
137 | ${ns}Current_Question__r.${ns}Answer_D__c
138 | FROM ${ns}Quiz_Session__c`;
139 | this.sfdc.query(soql, (error, result) => {
140 | if (error) {
141 | reject(error);
142 | } else if (result.records.length !== 1) {
143 | reject({
144 | message: 'Could not retrieve Quiz Session record.'
145 | });
146 | } else {
147 | const questionRecord =
148 | result.records[0][`${ns}Current_Question__r`];
149 | const question = {
150 | label: questionRecord[`${ns}Label__c`],
151 | answerA: questionRecord[`${ns}Answer_A__c`],
152 | answerB: questionRecord[`${ns}Answer_B__c`],
153 | answerC: questionRecord[`${ns}Answer_C__c`],
154 | answerD: questionRecord[`${ns}Answer_D__c`]
155 | };
156 | resolve(question);
157 | }
158 | });
159 | });
160 | }
161 | };
162 |
--------------------------------------------------------------------------------
/src/server/server.js:
--------------------------------------------------------------------------------
1 | const jsforce = require('jsforce'),
2 | Configuration = require('./utils/configuration.js'),
3 | WebSocketService = require('./utils/webSocketService.js'),
4 | QuizSessionRestResource = require('./rest/quiz-session.js'),
5 | PlayerRestResource = require('./rest/player.js'),
6 | AnswerRestResource = require('./rest/answer.js'),
7 | ConfigurationRestResource = require('./rest/configuration.js'),
8 | LWR = require('lwr'),
9 | express = require('express');
10 |
11 | // Load and check config
12 | require('dotenv').config();
13 | if (!Configuration.isValid()) {
14 | process.exit(-1);
15 | }
16 |
17 | // Configure server
18 | const lwrServer = LWR.createServer();
19 | const app = lwrServer.getInternalServer();
20 | const wss = new WebSocketService();
21 |
22 | // Connect to Salesforce
23 | const sfdc = new jsforce.Connection({
24 | loginUrl: Configuration.getSfLoginUrl(),
25 | version: Configuration.getSfApiVersion()
26 | });
27 | sfdc.login(
28 | Configuration.getSfUsername(),
29 | Configuration.getSfSecuredPassword(),
30 | (error) => {
31 | if (error) {
32 | console.error('Failed to connect to Salesforce org');
33 | console.error(error);
34 | process.exit(-1);
35 | }
36 | }
37 | ).then(() => {
38 | console.log('Connected to Salesforce');
39 | });
40 |
41 | // Prepare API server
42 | const apiServer = express();
43 | apiServer.use(express.json());
44 |
45 | // Setup Quiz Session REST resources
46 | const quizSessionRest = new QuizSessionRestResource(sfdc, wss);
47 | apiServer.get('/quiz-sessions', (request, response) => {
48 | quizSessionRest.getSession(request, response);
49 | });
50 | apiServer.put('/quiz-sessions', (request, response) => {
51 | quizSessionRest.updateSession(request, response);
52 | });
53 |
54 | // Setup Players REST resources
55 | const playerRest = new PlayerRestResource(sfdc);
56 | apiServer.get('/players', (request, response) => {
57 | playerRest.isNicknameAvailable(request, response);
58 | });
59 | apiServer.get('/players/:playerId/stats', (request, response) => {
60 | playerRest.getPlayerStats(request, response);
61 | });
62 | apiServer.get('/players/:playerId/leaderboard', (request, response) => {
63 | playerRest.getPlayerLeaderboard(request, response);
64 | });
65 | apiServer.post('/players', (request, response) => {
66 | playerRest.registerPlayer(request, response);
67 | });
68 |
69 | // Setup Answer REST resources
70 | const answerRest = new AnswerRestResource(sfdc);
71 | apiServer.post('/answers', (request, response) => {
72 | answerRest.submitAnswer(request, response);
73 | });
74 |
75 | // Setup Configuration REST resources
76 | const configurationRest = new ConfigurationRestResource();
77 | apiServer.get('/configuration', (request, response) => {
78 | configurationRest.getConfiguration(request, response);
79 | });
80 |
81 | // HTTP and WebSocket Listen
82 | app.use('/api', apiServer);
83 | wss.connect(lwrServer.server);
84 | lwrServer
85 | .listen(({ port, serverMode }) => {
86 | console.log(`App listening on port ${port} in ${serverMode} mode\n`);
87 | })
88 | .catch((err) => {
89 | console.error(err);
90 | process.exit(1);
91 | });
92 |
--------------------------------------------------------------------------------
/src/server/utils/configuration.js:
--------------------------------------------------------------------------------
1 | module.exports = class Configuration {
2 | static isValid() {
3 | [
4 | 'SF_USERNAME',
5 | 'SF_PASSWORD',
6 | 'SF_TOKEN',
7 | 'SF_LOGIN_URL',
8 | 'SF_API_VERSION',
9 | 'QUIZ_API_KEY'
10 | ].forEach((varName) => {
11 | if (!process.env[varName]) {
12 | console.error(`ERROR: Missing ${varName} environment variable`);
13 | return false;
14 | }
15 | });
16 | return true;
17 | }
18 |
19 | static getSfLoginUrl() {
20 | return process.env.SF_LOGIN_URL;
21 | }
22 |
23 | static getSfApiVersion() {
24 | return process.env.SF_API_VERSION;
25 | }
26 |
27 | static getSfUsername() {
28 | return process.env.SF_USERNAME;
29 | }
30 |
31 | static getSfSecuredPassword() {
32 | return process.env.SF_PASSWORD + process.env.SF_TOKEN;
33 | }
34 |
35 | static getSfNamespacePrefix() {
36 | return process.env.SF_NAMESPACE ? `${process.env.SF_NAMESPACE}__` : '';
37 | }
38 |
39 | static getSfNamespacePath() {
40 | return process.env.SF_NAMESPACE ? `/${process.env.SF_NAMESPACE}` : '';
41 | }
42 |
43 | static getQuizApiKey() {
44 | return process.env.QUIZ_API_KEY;
45 | }
46 |
47 | static shouldCollectPlayerEmails() {
48 | const value = process.env.COLLECT_PLAYER_EMAILS
49 | ? process.env.COLLECT_PLAYER_EMAILS.toUpperCase()
50 | : null;
51 | return value === 'TRUE';
52 | }
53 | };
54 |
--------------------------------------------------------------------------------
/src/server/utils/webSocketService.js:
--------------------------------------------------------------------------------
1 | const { WebSocket, WebSocketServer } = require('ws');
2 |
3 | const WSS_PING_INTERVAL = 29000;
4 |
5 | module.exports = class WebSocketService {
6 | constructor() {
7 | this.messageListeners = [];
8 | }
9 |
10 | connect(server) {
11 | // Start WebSocket server
12 | this.wss = new WebSocketServer({
13 | clientTracking: true,
14 | path: '/websockets',
15 | server
16 | });
17 |
18 | // Listen to lifecycle events
19 | this.wss.on('listening', () => {
20 | console.log('WebSocket server listening');
21 | });
22 | this.wss.on('error', (error) => {
23 | console.log(`WebSocket server error: ${error}`);
24 | });
25 | this.wss.on('close', () => {
26 | console.log('WebSocket server closed.');
27 | });
28 |
29 | // Listen for new client connections
30 | this.wss.on('connection', (wsClient) => {
31 | console.log('WS client connected');
32 | wsClient.isAlive = true;
33 |
34 | wsClient.on('message', (message) => {
35 | const data = JSON.parse(message);
36 | if (data.type === 'pong') {
37 | wsClient.isAlive = true;
38 | } else {
39 | console.log('WS incomming message ', data);
40 | this.messageListeners.forEach((listener) => {
41 | listener(data);
42 | });
43 | }
44 | });
45 |
46 | wsClient.on('close', () => {
47 | console.log('WS connection closed');
48 | });
49 | });
50 |
51 | // Check if WS clients are alive
52 | // eslint-disable-next-line @lwc/lwc/no-async-operation
53 | setInterval(() => {
54 | this.wss.clients.forEach((wsClient) => {
55 | if (!wsClient.isAlive) {
56 | console.log('WS removing inactive client');
57 | wsClient.terminate();
58 | } else {
59 | wsClient.isAlive = false;
60 | wsClient.send('{"type": "ping"}');
61 | }
62 | });
63 | }, WSS_PING_INTERVAL);
64 | }
65 |
66 | addMessageListener(listener) {
67 | this.messageListeners.push(listener);
68 | }
69 |
70 | /**
71 | * Broadcasts an object to all WS clients
72 | * @param {*} data object sent to WS client
73 | */
74 | broadcast(data) {
75 | console.log(
76 | `WS broadcasting to ${this.wss.clients.size} client(s): `,
77 | data
78 | );
79 | this.wss.clients.forEach((client) => {
80 | if (client.readyState === WebSocket.OPEN) {
81 | client.send(JSON.stringify(data), (error) => {
82 | if (error) {
83 | console.error('WS send error ', error);
84 | }
85 | });
86 | }
87 | });
88 | }
89 | };
90 |
--------------------------------------------------------------------------------
/test-scripts/change-phase.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | SCRIPT_PATH=$( cd "$(dirname "${BASH_SOURCE[0]}")" ; pwd -P )
3 | cd $SCRIPT_PATH
4 |
5 | HOST="http://0.0.0.0:3002"
6 |
7 | PHASE="$1"
8 | if [ "$PHASE" == "" ]; then
9 | echo "Select quiz phase:"
10 | select PHASE in "Registration" "PreQuestion" "Question" "QuestionResults" "GameResults"; do
11 | case $PHASE in
12 | Registration ) break;;
13 | PreQuestion ) break;;
14 | Question ) break;;
15 | QuestionResults ) break;;
16 | GameResults ) break;;
17 | esac
18 | done
19 | echo ""
20 | fi
21 |
22 | # Read API key from .env file
23 | API_KEY=$(grep API_KEY ../.env | cut -d '=' -f 2-)
24 |
25 | # Change quiz phase
26 | curl -X PUT \
27 | $HOST/api/quiz-sessions \
28 | -H "Api-Key: $API_KEY" \
29 | -H 'Cache-Control: no-cache' \
30 | -H 'Content-Type: application/json' \
31 | -d "{ \"phase\": \"$PHASE\" }"
32 | EXIT_CODE="$?"
33 |
34 | # Check exit code
35 | echo ""
36 | if [ "$EXIT_CODE" -eq 0 ]; then
37 | echo "Quiz phase changed to $PHASE"
38 | else
39 | echo "Execution failed."
40 | fi
41 | echo ""
42 | exit $EXIT_CODE
--------------------------------------------------------------------------------