├── .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 | Deploy 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 --------------------------------------------------------------------------------