diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..b38db2f --- /dev/null +++ b/.eslintignore @@ -0,0 +1,2 @@ +node_modules/ +build/ diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..5ce6064 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,16 @@ +{ + "extends": [ + "react-app", + "react-app/jest", + "plugin:storybook/recommended", + "plugin:prettier/recommended" + ], + "plugins": ["react", "prettier"], + "rules": { + "prettier/prettier": "error" + }, + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module" + } +} diff --git a/.firebase/hosting.YnVpbGQ.cache b/.firebase/hosting.YnVpbGQ.cache new file mode 100644 index 0000000..9aec138 --- /dev/null +++ b/.firebase/hosting.YnVpbGQ.cache @@ -0,0 +1,33 @@ +index.html,1725860405793,6310d84ffdca1cba888ffec82dec1bd853728243a85f931c7bbb1ea501234ed9 +robots.txt,1725860392506,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2 +logo192.png,1725860392504,f66564670b5bfe49f3f04a5b63107bae60f4a7736f1d3b83aa0115f7acf45150 +favicon.ico,1725860392503,1463be9bedc82e16f9364eec7a1f5152cbe56229401381eed65c1496cb884407 +asset-manifest.json,1725860405804,4e32ba450098f872b3a581873dc770a8b5c91d6d56f58e07f8ffe31b70c53ef7 +logo512.png,1725860392505,2356e0b814e58a349935c8de17226873d615d088a867d591fef48a64c80c9a2f +static/media/exit.5b9b641b90012b1192056a8c7e1b8c45.svg,1725860405791,4ff12475566874f3f3d3be8e8c97ca14a7b9292a04481947610725c16bb1599d +static/media/coffee.46aafd6dd9e4a1d3460e80ff9969dc6e.svg,1725860405791,f27933e7a7e8113c7a2be9eba7492922dd5918b77575f02a8ad595cb079e5547 +static/media/infinity.d099870a8639593717e3b17aea138c92.svg,1725860405791,70485bc15570a561994193d7e3e6b08a1f2de236248676255b871c8697e4c577 +manifest.json,1725860392505,4e1d3adad9ce7e17c0d13fac197f1c27091906f4ecf3909c9f51247bff345118 +static/media/cardo-greek-ext-400-normal.134b821611b16e279388.woff2,1725860405791,f47df12ed94c7e7bb8a2e37e21d10cde4a47fd41eeda611d924a55187ad01466 +static/media/cardo-latin-400-normal.4911b85d7a3867b3911f.woff2,1725860405791,8f43ac21350311e2925bf981b9fd8da34935abad28c7b1028c2ca2756af003f3 +static/media/cardo-greek-400-normal.79d37e879083ce714bd7.woff2,1725860405791,074bc81d8ca5a2111b391b121b56d17a67601361e97404d3d7792dbda1dbe5bd +static/media/cardo-greek-400-normal.82ddcf833fd34281b75d.woff,1725860405791,3b4c19d6c84e7a572ee345b68fc874deeec43d4690c7d590a9029fddf3dbddf5 +static/media/bracket.910c02ed0a238012f80f22086064c5d9.svg,1725860405791,fc713f945dd7cd2ca06c86d392ebdea3a6cf04a3e009e72877aec5e3be787486 +static/media/cardo-greek-ext-400-normal.f2008e9d17e15badfffd.woff,1725860405791,f3e91ac60b6103eb6065b6d12d6adb656d95b1ab9904f98d15916b49849385ec +static/js/main.4e103dc0.js.LICENSE.txt,1725860405805,fd222b36f412805419e026feba067f18ca36cf439c430ae6dfd4bf072d541304 +static/js/453.18d94f3f.chunk.js,1725860405805,0c85c1b7dd1771b07c17867a1b4cc9147aac500e2761b78e5f43c3af5a990b97 +static/js/453.18d94f3f.chunk.js.map,1725860405805,01852c42a7189dcf6095a7437716308dcf1058fbf9afeee17e1d113d8fc0016f +static/css/main.f0edc1a0.css,1725860405805,1c87f7cfa7da9415d7b6abe70cf41d8705f1d751ee0f4d6b406baafbb9bcca8b +static/css/main.f0edc1a0.css.map,1725860405805,1a40319fdab387a87e11b7c3aadf554d86722ada29a129ba316b832bcdbaefa0 +static/media/cardo-latin-ext-400-normal.d90efa9a5c52b57c81be.woff2,1725860405792,ac5c40b8f0f0170763306cbd56736ea93f3bc8a46bd69691097be496e3b9a604 +static/media/cardo-latin-400-normal.a3fc903c20de90e51735.woff,1725860405791,cf7c3f21e093e6af8f4490da55d497398a38ff9485afae511311013338b8fe82 +static/media/cardo-latin-ext-400-normal.448cff0b9a0afb700151.woff,1725860405791,b285c4d668a7c861ca15b7dd3e126f26f6c06449c41a2c823a10146b0550089c +static/media/Blackmild Regular.5b441560e0d850038d0f.otf,1725860405791,60300d3ccf8a22a528b216703a9677022b27e7f05fe71f9768e58220c132a4c9 +static/media/Cinzel-VariableFont_wght.2d087b545bf495f3d86d.ttf,1725860405791,3394f58fb0427fbe8d8dcc90863079661330c57c41d22309fabdda6f9bbbf7e3 +static/media/Cardo-Regular.4ad75d4da97e1cf6655b.ttf,1725860405791,9441d454efe51434f20f9f848652531e4caa3ffa57a3c7d2caa08370b01b2c63 +static/media/card.1d7fa489536497609c42.png,1725860405805,aed32aacda18dd89590e88b93da24764ec4c8d3a94f9390d90b241fb88f20788 +static/js/main.4e103dc0.js,1725860405805,20a9e0c99d6ba25a839ea31b3fcceb3d71c1e6835061ddb862bc51a56f0cf0ca +static/media/applogo.9a90eed8f247993fa372.png,1725860405805,162fdef5335b42be3160731c6f9472f17d8a47b1a91f0f14f2a4fcf3b46559cd +static/media/titlecard.420823b8e1f5db1380e4.png,1725860405806,77bccf483368ffe9622e590756dac4c2011d125589802b193a9d9ac79f1ddf0b +static/media/cardback.6c07d542c2853cbf82d3.png,1725860405806,4cd2749fe35df98d738b7504afe8469dbdd2ba23101544b81c9592be8c5321f4 +static/js/main.4e103dc0.js.map,1725860405807,61fad40e9da546f6b8440539286164bb2d5d301525c6149d772184ed61d7dc04 diff --git a/.firebase/hosting.cHVibGlj.cache b/.firebase/hosting.cHVibGlj.cache index f0428a1..772ab51 100644 --- a/.firebase/hosting.cHVibGlj.cache +++ b/.firebase/hosting.cHVibGlj.cache @@ -1,6 +1,6 @@ robots.txt,1724736751497,391d14b3c2f8c9143a27a28c7399585142228d4d1bdbe2c87ac946de411fa9a2 manifest.json,1724736751496,4e1d3adad9ce7e17c0d13fac197f1c27091906f4ecf3909c9f51247bff345118 -index.html,1724743290229,478e680598c372dc98351e6f43bf53591fe6607c1b1f12fc288e36966444171a -logo192.png,1724736751497,f66564670b5bfe49f3f04a5b63107bae60f4a7736f1d3b83aa0115f7acf45150 logo512.png,1724736751496,2356e0b814e58a349935c8de17226873d615d088a867d591fef48a64c80c9a2f +logo192.png,1724736751497,f66564670b5bfe49f3f04a5b63107bae60f4a7736f1d3b83aa0115f7acf45150 +index.html,1725573204717,aa536089eb6759ecf98bc29cb90c3ff760a32446e3c3bef5443cb721dc81efeb favicon.ico,1724736751495,1463be9bedc82e16f9364eec7a1f5152cbe56229401381eed65c1496cb884407 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..b38db2f --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +node_modules/ +build/ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..ad4e77c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "es5", + "printWidth": 80, + "tabWidth": 2 +} \ No newline at end of file diff --git a/.storybook/main.js b/.storybook/main.js index dd792d8..ace1695 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -8,6 +8,7 @@ const config = { "@storybook/addon-essentials", "@chromatic-com/storybook", "@storybook/addon-interactions", + "storybook-addon-mock/register" ], framework: { name: "@storybook/react-webpack5", diff --git a/.yarn/install-state.gz b/.yarn/install-state.gz index a6242e6..adf05de 100644 Binary files a/.yarn/install-state.gz and b/.yarn/install-state.gz differ diff --git a/database-debug.log b/database-debug.log new file mode 100644 index 0000000..3e5ee87 --- /dev/null +++ b/database-debug.log @@ -0,0 +1,9 @@ +21:13:22.421 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO akka.event.slf4j.Slf4jLogger - Slf4jLogger started +21:13:22.572 [main] INFO com.firebase.server.forge.App$ - Listening at 127.0.0.1:9000 +21:13:31.975 [NamespaceSystem-akka.actor.default-dispatcher-4] INFO com.firebase.core.namespace.NamespaceActor - fibonacci-fold-default-rtdb successfully activated FBKV (SurveyIdle(0)) wait: 121ms, init: 0ms +21:13:32.035 [NamespaceSystem-blocking-namespace-operation-dispatcher-6] INFO com.firebase.core.namespace.StateManager - Namespace fibonacci-fold-default-rtdb status Active to Active +21:14:00.571 [FirebaseWorkerPool-1-2] WARN com.firebase.core.persistence.backend.dummy.EmulatorMetadataPersistence - Multiple projectIds are not recommended in single project mode. Requested +namespace fake-project-id, but the emulator is configured for fibonacci-fold-default-rtdb. To +opt-out of single project mode add/set the singleProjectMode: false property in +the firebase.json emulators config. +21:14:00.575 [NamespaceSystem-akka.actor.default-dispatcher-10] INFO com.firebase.core.namespace.NamespaceActor - fake-project-id successfully activated FBKV (SurveyIdle(0)) wait: 1ms, init: 0ms diff --git a/database.rules.json b/database.rules.json index f54493d..a994485 100644 --- a/database.rules.json +++ b/database.rules.json @@ -1,7 +1,12 @@ { - /* Visit https://firebase.google.com/docs/database/security to learn more about security rules. */ "rules": { - ".read": false, - ".write": false + ".read": "auth != null", + ".write": "auth != null", + "users": { + "$uid": { + ".read": "$uid === auth.uid", + ".write": "$uid === auth.uid" + } + } } -} \ No newline at end of file +} diff --git a/firebase.json b/firebase.json index 94a0b21..72ddcfa 100644 --- a/firebase.json +++ b/firebase.json @@ -3,17 +3,28 @@ "rules": "database.rules.json" }, "hosting": { - "public": "public", - "ignore": [ - "firebase.json", - "**/.*", - "**/node_modules/**" - ], + "public": "build", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], "rewrites": [ { "source": "**", "destination": "/index.html" } ] + }, + "emulators": { + "auth": { + "port": 9099 + }, + "database": { + "port": 9000 + }, + "hosting": { + "port": 5000 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true } } diff --git a/firestore-debug.log b/firestore-debug.log new file mode 100644 index 0000000..656790c --- /dev/null +++ b/firestore-debug.log @@ -0,0 +1,16 @@ +Aug 31, 2024 1:39:31 AM com.google.cloud.datastore.emulator.firestore.websocket.WebSocketServer start +INFO: Started WebSocket server on ws://127.0.0.1:9150 +API endpoint: http://127.0.0.1:8080 +If you are using a library that supports the FIRESTORE_EMULATOR_HOST environment variable, run: + + export FIRESTORE_EMULATOR_HOST=127.0.0.1:8080 + +If you are running a Firestore in Datastore Mode project, run: + + export DATASTORE_EMULATOR_HOST=127.0.0.1:8080 + +Note: Support for Datastore Mode is in preview. If you encounter any bugs please file at https://github.com/firebase/firebase-tools/issues. +Dev App Server is now running. + +*** shutting down gRPC server since JVM is shutting down +*** server shut down diff --git a/package.json b/package.json index fed4401..10e10ad 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,13 @@ "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "crypto-js": "^4.2.0", "firebase": "^10.13.0", "react": "^18.3.1", "react-dom": "^18.3.1", + "react-router-dom": "^6.26.1", "react-scripts": "5.0.1", + "uuid": "^10.0.0", "web-vitals": "^2.1.4" }, "scripts": { @@ -19,14 +22,23 @@ "test": "react-scripts test", "eject": "react-scripts eject", "storybook": "storybook dev -p 6006", - "build-storybook": "storybook build" + "build-storybook": "storybook build", + "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}' --fix" }, "eslintConfig": { "extends": [ "react-app", "react-app/jest", - "plugin:storybook/recommended" - ] + "plugin:storybook/recommended", + "plugin:prettier/recommended" + ], + "plugins": [ + "react", + "prettier" + ], + "rules": { + "prettier/prettier": "error" + } }, "browserslist": { "production": [ @@ -41,6 +53,7 @@ ] }, "devDependencies": { + "@babel/eslint-parser": "^7.25.1", "@chromatic-com/storybook": "^1.7.0", "@storybook/addon-essentials": "^8.2.9", "@storybook/addon-interactions": "^8.2.9", @@ -51,11 +64,21 @@ "@storybook/react": "^8.2.9", "@storybook/react-webpack5": "^8.2.9", "@storybook/test": "^8.2.9", + "dotenv": "^16.4.5", + "eslint": "^8.52.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-react": "^7.35.0", "eslint-plugin-storybook": "^0.8.0", + "firebase-mock": "^2.3.2", "firebase-tools": "^13.15.4", + "lint": "^0.8.19", + "prettier": "^3.3.3", "prop-types": "^15.8.1", "storybook": "^8.2.9", + "storybook-addon-mock": "^5.0.0", "webpack": "^5.94.0" }, - "packageManager": "yarn@4.4.1" + "packageManager": "yarn@4.4.1", + "homepage": "/" } diff --git a/public/index.html b/public/index.html index d87170d..ef643e6 100644 --- a/public/index.html +++ b/public/index.html @@ -1,89 +1,20 @@ - - + + - - - Welcome to Firebase Hosting - - - - - - - - - - - - - - - - - + + + + + + + + React App -
-

Welcome

-

Firebase Hosting Setup Complete

-

You're seeing this because you've successfully setup Firebase Hosting. Now it's time to go build something extraordinary!

- Open Hosting Documentation -
-

Firebase SDK Loading…

- - + +
diff --git a/pubsub-debug.log b/pubsub-debug.log new file mode 100644 index 0000000..385202f --- /dev/null +++ b/pubsub-debug.log @@ -0,0 +1,11 @@ +This is the Google Pub/Sub fake. +Implementation may be incomplete or differ from the real system. +Aug 31, 2024 1:39:41 AM com.google.cloud.pubsub.testing.v1.Main main +INFO: IAM integration is disabled. IAM policy methods and ACL checks are not supported +SLF4J: Failed to load class "org.slf4j.impl.StaticLoggerBinder". +SLF4J: Defaulting to no-operation (NOP) logger implementation +SLF4J: See http://www.slf4j.org/codes.html#StaticLoggerBinder for further details. +Aug 31, 2024 1:39:42 AM com.google.cloud.pubsub.testing.v1.Main main +INFO: Server started, listening on 8085 +*** shutting down gRPC server since JVM is shutting down +*** server shut down diff --git a/src/App.jsx b/src/App.jsx index 32dd243..53733f7 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,49 +1,59 @@ -import React, { useState } from 'react'; -import './App.css'; -import VoteCard from './components/VoteCard/VoteCard'; -import VotingBoard from './components/VotingBoard/VotingBoard'; +import React, { useEffect, useState } from 'react'; +import { + BrowserRouter as Router, + Routes, + Route, + Navigate, +} from 'react-router-dom'; +import Lobby from './components/Lobby/Lobby'; +import VotingTable from './components/VotingTable/VotingTable'; +import UserSessionListener from './components/UserSessionListener/UserSessionListener'; +import { signInAnonymously, onAuthStateChanged } from 'firebase/auth'; +import { auth } from './services/firebase'; -// Import the functions you need from the SDKs you need -import { initializeApp } from "firebase/app"; -import { getAnalytics } from "firebase/analytics"; +const App = () => { + const [userId, setUserId] = useState(''); + const [loading, setLoading] = useState(true); -// Your web app's Firebase configuration -// For Firebase JS SDK v7.20.0 and later, measurementId is optional -const firebaseConfig = { - apiKey: REACT_APP_FIREBASE_API_KEY, - authDomain: REACT_APP_FIREBASE_AUTH_DOMAIN, - projectId: REACT_APP_FIREBASE_PROJECT_ID, - storageBucket: REACT_APP_FIREBASE_STORAGE_BUCKET, - messagingSenderId: REACT_APP_FIREBASE_MESSAGING_SENDER_ID, - appId: REACT_APP_FIREBASE_APP_ID, - measurementId: REACT_APP_FIREBASE_DATABASE_URL, -}; - -// Initialize Firebase -const app = initializeApp(firebaseConfig); -const analytics = getAnalytics(app); - -function App() { - const [selectedVote, setSelectedVote] = useState(null); - - const handleVoteClick = (number) => { - setSelectedVote(number); + const handleJoinSession = (userId) => { + setUserId(userId); + return true; }; + useEffect(() => { + // Sign in the user anonymously + signInAnonymously(auth).catch((error) => { + console.error('Anonymous sign-in error:', error); + }); + + // Handle user state changes + onAuthStateChanged(auth, (user) => { + if (user) { + console.log('User is signed in:', user); + } else { + console.log('User is signed out'); + } + setLoading(false); + }); + }, []); + return ( -
-
-

Fibonacci Voting App

-
- -
-

Pick Your Vote

-
- -
-
-
+ + + + } + loading={loading} + /> + } + /> + } /> + + ); -} +}; export default App; diff --git a/src/assets/fonts/Blackmild Regular/Blackmild Regular.otf b/src/assets/fonts/Blackmild Regular/Blackmild Regular.otf new file mode 100755 index 0000000..4b2f9e7 Binary files /dev/null and b/src/assets/fonts/Blackmild Regular/Blackmild Regular.otf differ diff --git a/src/assets/fonts/Cardo/Cardo-Bold.ttf b/src/assets/fonts/Cardo/Cardo-Bold.ttf new file mode 100644 index 0000000..bbc41e6 Binary files /dev/null and b/src/assets/fonts/Cardo/Cardo-Bold.ttf differ diff --git a/src/assets/fonts/Cardo/Cardo-Italic.ttf b/src/assets/fonts/Cardo/Cardo-Italic.ttf new file mode 100644 index 0000000..5481554 Binary files /dev/null and b/src/assets/fonts/Cardo/Cardo-Italic.ttf differ diff --git a/src/assets/fonts/Cardo/Cardo-Regular.ttf b/src/assets/fonts/Cardo/Cardo-Regular.ttf new file mode 100644 index 0000000..3dc6319 Binary files /dev/null and b/src/assets/fonts/Cardo/Cardo-Regular.ttf differ diff --git a/src/assets/fonts/Cinzel/Cinzel-VariableFont_wght.ttf b/src/assets/fonts/Cinzel/Cinzel-VariableFont_wght.ttf new file mode 100644 index 0000000..38ae229 Binary files /dev/null and b/src/assets/fonts/Cinzel/Cinzel-VariableFont_wght.ttf differ diff --git a/src/assets/fonts/fonts.css b/src/assets/fonts/fonts.css new file mode 100644 index 0000000..af5e266 --- /dev/null +++ b/src/assets/fonts/fonts.css @@ -0,0 +1,22 @@ +@font-face { + font-family: 'blackmild-regular'; + src: url('/src/assets/fonts/Blackmild\ Regular/Blackmild\ Regular.otf') + format('opentype'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'cardo-regular'; + src: url('/src/assets/fonts/Cardo/Cardo-Regular.ttf') format('opentype'); + font-weight: normal; + font-style: normal; +} + +@font-face { + font-family: 'cinzel'; + src: url('/src/assets/fonts/Cinzel/Cinzel-VariableFont_wght.ttf') + format('opentype'); + font-weight: normal; + font-style: normal; +} diff --git a/src/assets/images/2cards.xcf b/src/assets/images/2cards.xcf new file mode 100644 index 0000000..c732e87 Binary files /dev/null and b/src/assets/images/2cards.xcf differ diff --git a/src/assets/images/applogo.png b/src/assets/images/applogo.png new file mode 100644 index 0000000..f0713b4 Binary files /dev/null and b/src/assets/images/applogo.png differ diff --git a/src/assets/images/applogo.xcf b/src/assets/images/applogo.xcf new file mode 100644 index 0000000..37e5c7c Binary files /dev/null and b/src/assets/images/applogo.xcf differ diff --git a/src/assets/images/card.png b/src/assets/images/card.png new file mode 100644 index 0000000..59787cd Binary files /dev/null and b/src/assets/images/card.png differ diff --git a/src/assets/images/card.xcf b/src/assets/images/card.xcf new file mode 100644 index 0000000..636aa5c Binary files /dev/null and b/src/assets/images/card.xcf differ diff --git a/src/assets/images/cardback.png b/src/assets/images/cardback.png index 2662e37..a04f210 100644 Binary files a/src/assets/images/cardback.png and b/src/assets/images/cardback.png differ diff --git a/src/assets/images/cardback.xcf b/src/assets/images/cardback.xcf new file mode 100644 index 0000000..dc6f30a Binary files /dev/null and b/src/assets/images/cardback.xcf differ diff --git a/src/assets/images/coffee.png b/src/assets/images/coffee.png deleted file mode 100644 index 853e229..0000000 Binary files a/src/assets/images/coffee.png and /dev/null differ diff --git a/src/assets/images/coffee.svg b/src/assets/images/coffee.svg index e6cc857..5645cdc 100644 --- a/src/assets/images/coffee.svg +++ b/src/assets/images/coffee.svg @@ -1,4 +1,4 @@ - - + + diff --git a/src/assets/images/exit.svg b/src/assets/images/exit.svg new file mode 100644 index 0000000..419e30f --- /dev/null +++ b/src/assets/images/exit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/images/infinity.svg b/src/assets/images/infinity.svg index 558ecb4..21cce0b 100644 --- a/src/assets/images/infinity.svg +++ b/src/assets/images/infinity.svg @@ -1,3 +1,3 @@ - - + + diff --git a/src/assets/images/roundtable.png b/src/assets/images/roundtable.png new file mode 100644 index 0000000..f8b24cb Binary files /dev/null and b/src/assets/images/roundtable.png differ diff --git a/src/assets/images/roundtable.xcf b/src/assets/images/roundtable.xcf new file mode 100644 index 0000000..7ee6136 Binary files /dev/null and b/src/assets/images/roundtable.xcf differ diff --git a/src/assets/images/titlecard.png b/src/assets/images/titlecard.png new file mode 100644 index 0000000..88333fd Binary files /dev/null and b/src/assets/images/titlecard.png differ diff --git a/src/assets/images/titlecard.xcf b/src/assets/images/titlecard.xcf new file mode 100644 index 0000000..37c9449 Binary files /dev/null and b/src/assets/images/titlecard.xcf differ diff --git a/src/components/BoardCard/BoardCard.jsx b/src/components/BoardCard/BoardCard.jsx new file mode 100644 index 0000000..4a45967 --- /dev/null +++ b/src/components/BoardCard/BoardCard.jsx @@ -0,0 +1,26 @@ +import React from 'react'; +import cardBack from '../../assets/images/cardback.png'; +import { ReactComponent as BracketIcon } from '../../assets/images/bracket.svg'; +import FrontCard from '../FrontCard/FrontCard'; +import styles from './BoardCard.module.css'; + +const BoardCard = ({ username, vote, isRevealed, isDimmed, isHighlighted }) => { + return ( +
+
+ {isHighlighted ? : null} +
+
+ Card Back +
+ +
+
+
{username}
+
+ ); +}; + +export default BoardCard; diff --git a/src/components/BoardCard/BoardCard.module.css b/src/components/BoardCard/BoardCard.module.css new file mode 100644 index 0000000..b79ce97 --- /dev/null +++ b/src/components/BoardCard/BoardCard.module.css @@ -0,0 +1,73 @@ +.boardCard { + perspective: 1000px; + max-width: 120px; + min-width: 60px; + padding: 0.1rem; + margin: 10px; + opacity: 1; + position: relative; + display: inline-flex; + flex-flow: column wrap; + align-items: center; + justify-content: flex-start; +} + +.boardCard .username { + word-break: break-word; +} + +.boardCard .dimmed { + opacity: 0.3; +} + +.boardCard .highlight { + /* flex-wrap: nowrap; */ + /* position: absolute; */ + /* transform: translate(-0%, -100%); */ + /* overflow: visible; */ + /* height: 20%; + align-self: center; + justify-content: center; */ + /* max-width: 115px; */ + display: flex; + flex-wrap: nowrap; + max-height: 50px; +} + +.boardCard .highlightIconContainer, +.boardCard .highlightIconContainer svg { + width: 50px; + height: 25px; +} + +.card { + width: 50px; + height: 75px; + transition: transform 0.6s ease-in-out; + transform-style: preserve-3d; + position: relative; + margin: auto; +} + +.card.flip { + transform: rotateY(180deg); +} + +.card img, +.card .front { + backface-visibility: hidden; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; +} + +.card .front { + transform: rotateY( + 180deg + ); /* This makes the front face appear on the back initially */ +} diff --git a/src/components/BoardCard/BoardCard.stories.js b/src/components/BoardCard/BoardCard.stories.js new file mode 100644 index 0000000..f932a6a --- /dev/null +++ b/src/components/BoardCard/BoardCard.stories.js @@ -0,0 +1,9 @@ +import React from 'react'; +import BoardCard from './BoardCard'; + +export default { + title: 'Components/BoardCard', + component: BoardCard, +}; + +export const Default = () => ; diff --git a/src/components/CardBack/CardBack.jsx b/src/components/CardBack/CardBack.jsx deleted file mode 100644 index d08acd5..0000000 --- a/src/components/CardBack/CardBack.jsx +++ /dev/null @@ -1,10 +0,0 @@ -import React from 'react'; - -import styles from './CardBack.module.css'; -import cardBack from '../../assets/images/cardback.png'; - -const CardBack = () => ( - -); - -export default CardBack; diff --git a/src/components/CardBack/CardBack.stories.js b/src/components/CardBack/CardBack.stories.js deleted file mode 100644 index 26201cd..0000000 --- a/src/components/CardBack/CardBack.stories.js +++ /dev/null @@ -1,9 +0,0 @@ -import React from 'react'; -import CardBack from './CardBack'; - -export default { - title: 'Components/CardBack', - component: CardBack, -}; - -export const Default = () => ; diff --git a/src/components/FrontCard/FrontCard.jsx b/src/components/FrontCard/FrontCard.jsx new file mode 100644 index 0000000..c5faa48 --- /dev/null +++ b/src/components/FrontCard/FrontCard.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import card from '../../assets/images/card.png'; +import VoteValue from '../VoteValue/VoteValue'; +import styles from './FrontCard.module.css'; + +const FrontCard = ({ value, className }) => { + return ( +
+ Card Front + +
+ ); +}; + +export default FrontCard; diff --git a/src/components/FrontCard/FrontCard.module.css b/src/components/FrontCard/FrontCard.module.css new file mode 100644 index 0000000..0eafd1e --- /dev/null +++ b/src/components/FrontCard/FrontCard.module.css @@ -0,0 +1,30 @@ +.cardFrontContainer { + width: 100%; + height: 100%; + position: relative; + display: flex; + justify-content: center; + align-items: center; +} + +.cardFront { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; +} + +.vote { + z-index: 1; + display: flex; + justify-content: center; + align-items: center; + font-size: 2rem; + width: 80%; +} + +.voteValue { + color: #1b191a; + text-shadow: none; +} diff --git a/src/components/FrontCard/FrontCard.stories.js b/src/components/FrontCard/FrontCard.stories.js new file mode 100644 index 0000000..5322021 --- /dev/null +++ b/src/components/FrontCard/FrontCard.stories.js @@ -0,0 +1,31 @@ +import React from 'react'; +import FrontCard from './FrontCard'; + +export default { + title: 'Components/FrontCard', + component: FrontCard, +}; + +const Template = (args) => ( +
+ +
+); + +export const Default = Template.bind({}); +Default.args = { + value: 5, + parentStyle: { width: 50, height: 75 }, +}; + +export const AlternateValue = Template.bind({}); +AlternateValue.args = { + value: 8, + parentStyle: { width: 50, height: 75 }, +}; + +export const EnlargedParent = Template.bind({}); +EnlargedParent.args = { + value: 8, + parentStyle: { width: 100, height: 150 }, +}; diff --git a/src/components/Lobby/Lobby.jsx b/src/components/Lobby/Lobby.jsx new file mode 100644 index 0000000..aff220d --- /dev/null +++ b/src/components/Lobby/Lobby.jsx @@ -0,0 +1,114 @@ +import React, { useState, useEffect } from 'react'; +import { ref, onValue, set } from 'firebase/database'; +import { realtimeDb } from '../../services/firebase'; +import { generateUniqueId } from '../../utils/crypto'; +import appLogo from '../../assets/images/applogo.png'; +import styles from './Lobby.module.css'; +import { useNavigate, useParams } from 'react-router-dom'; +import { auth } from '../../services/firebase'; + +const Lobby = ({ onJoinSession = () => {} }) => { + const navigate = useNavigate(); + const [username, setUsername] = useState(''); + const [users, setUsers] = useState([]); + const [isUsernameTaken, setIsUsernameTaken] = useState(false); + const { sessionId } = useParams(); + + useEffect(() => { + // Get or generate the session ID + if (!sessionId) { + const newSessionId = generateUniqueId(); + // Update the URL with the session ID if it wasn't already there + navigate(`/lobby/${newSessionId}`); + } + + const sessionRef = ref(realtimeDb, `sessions/${sessionId}/users`); + + // Listen for changes to the session users data + const unsubscribe = onValue(sessionRef, (snapshot) => { + const users = snapshot.val(); + if (!users) return; + + const userList = Object.entries(users).map(([uid, userInfo]) => ({ + uid, + username: userInfo.username, + })); + setUsers(userList); + }); + + return () => { + unsubscribe(); + }; + }, [navigate, sessionId]); + + const handleJoinSession = async () => { + const user = auth.currentUser; + const userId = user ? user.uid : generateUniqueId(); + + // Check if the uid is already part of the session + if (users.includes(userId)) { + setIsUsernameTaken(true); + return; + } else { + // Set the initial vote for the user + const voteRef = ref(realtimeDb, `sessions/${sessionId}/votes/${userId}`); + await set(voteRef, -1); + + // Add the user to the session's user list with their uid and username + const userRef = ref(realtimeDb, `sessions/${sessionId}/users/${userId}`); + await set(userRef, { + username: username, + uid: userId, + }); + + // Also save the current session ID under the user's record in the database + const userSessionRef = ref(realtimeDb, `users/${userId}`); + await set(userSessionRef, { + currentSessionId: `session/${sessionId}`, + }); + + // Call the onJoinSession callback with the username + onJoinSession(userId); + + // Navigate to the session + navigate(`/session/${sessionId}`); + } + }; + + const handleKeyDown = (event) => { + if (event.key === 'Enter') { + handleJoinSession(); + } + }; + + return ( +
+ logo +

Enter Your Username

+
+ setUsername(e.target.value)} + maxLength={24} + onKeyDown={handleKeyDown} + /> +
+ Join Session +
+
+ {isUsernameTaken && ( +

Username is already taken. Please choose another.

+ )} +
+

Current Users In Session

+
+ {users.map(({ uid, username }) => ( +
{username}
+ ))} +
+
+ ); +}; + +export default Lobby; diff --git a/src/components/Lobby/Lobby.module.css b/src/components/Lobby/Lobby.module.css new file mode 100644 index 0000000..bc696c5 --- /dev/null +++ b/src/components/Lobby/Lobby.module.css @@ -0,0 +1,67 @@ +.lobby { + text-align: center; + padding: 20px; +} + +.lobby .appLogo { + max-width: 500px; + width: calc(100% - 10px); +} + +.lobby ul { + list-style-type: none; + padding: 0; +} + +.lobby li { + margin: 10px 0; + font-size: 18px; +} + +.lobby .form { + display: flex; + justify-content: center; +} + +.lobby .form .button { + background-color: #f0dcb1; + border: 1px solid black; + border-style: double; + color: black; + text-shadow: none; + padding: 4px 8px; + cursor: pointer; + font-weight: 800; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 1); + border-radius: 4px; + transition: background-color 0.3s ease; +} + +.lobby .form .button:hover { + box-shadow: 2px 2px 5px rgba(0, 0, 0, 1); + background-color: #cdba95; +} + +.lobby .form .button:active { + transform: scale(0.95); + box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.5); +} + +.lobby .form input { + margin-right: 8px; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 1); +} + +.lobby .list { + display: flex; + justify-content: center; + flex-wrap: wrap; +} + +.lobby .list section { + display: inline-flex; + align-items: center; + justify-content: center; + margin: 10px; + font-size: 18px; +} diff --git a/src/components/Lobby/Lobby.stories.js b/src/components/Lobby/Lobby.stories.js new file mode 100644 index 0000000..95b987d --- /dev/null +++ b/src/components/Lobby/Lobby.stories.js @@ -0,0 +1,14 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import Lobby from './Lobby'; + +export default { + title: 'Components/Lobby', + component: Lobby, +}; + +export const Default = () => ( + + {}} /> + +); diff --git a/src/components/Lobby/Lobby.test.js b/src/components/Lobby/Lobby.test.js new file mode 100644 index 0000000..691ba74 --- /dev/null +++ b/src/components/Lobby/Lobby.test.js @@ -0,0 +1,8 @@ +import { render, screen } from '@testing-library/react'; +import Lobby from './Lobby'; + +test('renders voting Lobby', () => { + const mockOnJoinSession = jest.fn(); + render(); + expect(screen.getByText(/5: 2 vote\(s\)/i)).toBeInTheDocument(); +}); diff --git a/src/components/Results/Results.jsx b/src/components/Results/Results.jsx index 1d0fcf6..7da4b8b 100644 --- a/src/components/Results/Results.jsx +++ b/src/components/Results/Results.jsx @@ -1,24 +1,31 @@ import React from 'react'; import styles from './Results.module.css'; +import VoteValue from '../VoteValue/VoteValue'; const Results = ({ votes }) => { - const voteCounts = Object.values(votes).reduce((acc, vote) => { - acc[vote] = (acc[vote] || 0) + 1; - return acc; - }, {}); + const voteCounts = Object.values(votes).reduce((acc, vote) => { + acc[vote] = (acc[vote] || 0) + 1; + return acc; + }, {}); - return ( -
-

Voting Results

-
    - {Object.keys(voteCounts).map((vote, index) => ( -
  • - {vote}: {voteCounts[vote]} vote(s) -
  • - ))} -
-
- ); + return ( +
+

Voting Results

+
+ {Object.keys(voteCounts).map((vote, index) => + vote >= 0 ? ( +
+
+ +
+ {' : '} + {voteCounts[vote]} vote(s) +
+ ) : null + )} +
+
+ ); }; export default Results; diff --git a/src/components/Results/Results.module.css b/src/components/Results/Results.module.css index db80d8c..cce8aa8 100644 --- a/src/components/Results/Results.module.css +++ b/src/components/Results/Results.module.css @@ -1,14 +1,28 @@ .results { - text-align: center; - padding: 20px; + text-align: center; + padding: 20px; } -.results ul { - list-style-type: none; - padding: 0; +.results .list { + display: flex; + justify-content: center; + flex-wrap: wrap; } -.results li { - margin: 10px 0; - font-size: 18px; +.results .list .pair { + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0 10px 10px; + font-size: 18px; +} + +.results .voteValueContainer { + max-width: 2rem; + margin-right: 4px; +} + +.results .voteValueContainer .voteValue { + transform: scale(0.8); + align-self: center; } diff --git a/src/components/Results/Results.stories.js b/src/components/Results/Results.stories.js index b5244a9..ed2556f 100644 --- a/src/components/Results/Results.stories.js +++ b/src/components/Results/Results.stories.js @@ -2,8 +2,8 @@ import React from 'react'; import Results from './Results'; export default { - title: 'Components/Results', - component: Results, + title: 'Components/Results', + component: Results, }; const mockVotes = { user1: 5, user2: 8, user3: 5 }; diff --git a/src/components/Results/Results.test.js b/src/components/Results/Results.test.js index 1070ee5..b374f55 100644 --- a/src/components/Results/Results.test.js +++ b/src/components/Results/Results.test.js @@ -2,8 +2,8 @@ import { render, screen } from '@testing-library/react'; import Results from './Results'; test('renders voting results', () => { - const mockVotes = { user1: 5, user2: 8, user3: 5 }; - render(); - expect(screen.getByText(/5: 2 vote\(s\)/i)).toBeInTheDocument(); - expect(screen.getByText(/8: 1 vote\(s\)/i)).toBeInTheDocument(); + const mockVotes = { user1: 5, user2: 8, user3: 5 }; + render(); + expect(screen.getByText(/5: 2 vote\(s\)/i)).toBeInTheDocument(); + expect(screen.getByText(/8: 1 vote\(s\)/i)).toBeInTheDocument(); }); diff --git a/src/components/UserSessionListener/UserSessionListener.jsx b/src/components/UserSessionListener/UserSessionListener.jsx new file mode 100644 index 0000000..fcc13b0 --- /dev/null +++ b/src/components/UserSessionListener/UserSessionListener.jsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react'; +import { ref, onValue, remove } from 'firebase/database'; +import { realtimeDb } from '../../services/firebase'; +import { useNavigate } from 'react-router-dom'; +import { generateUniqueId } from '../../utils/crypto'; +import { auth } from '../../services/firebase'; + +const UserSessionListener = () => { + const navigate = useNavigate(); + const user = auth.currentUser; + const userId = user ? user.uid : generateUniqueId(); + const [isRedirecting, setIsRedirecting] = useState(false); + + useEffect(() => { + const userRef = ref(realtimeDb, `users/${userId}/currentSessionId`); + + const unsubscribe = onValue(userRef, async (snapshot) => { + const currentSessionId = snapshot.val() || generateUniqueId(); + + // Only redirect if the user is not already being redirected + if ( + !isRedirecting && + currentSessionId && + currentSessionId.startsWith('lobby') + ) { + setIsRedirecting(true); + + console.log(`Removing user from: users/${userId}`); + + // Remove the entire user entry from the database + const fullUserRef = ref(realtimeDb, `users/${userId}`); + await remove(fullUserRef); + + navigate(`/${currentSessionId}`); + + setIsRedirecting(false); + } + }); + + return () => unsubscribe(); + }, [userId, navigate, isRedirecting]); + + return null; +}; + +export default UserSessionListener; diff --git a/src/components/VoteCard/VoteCard.jsx b/src/components/VoteCard/VoteCard.jsx index bc81b96..dfc69cd 100644 --- a/src/components/VoteCard/VoteCard.jsx +++ b/src/components/VoteCard/VoteCard.jsx @@ -1,20 +1,21 @@ import React from 'react'; import styles from './VoteCard.module.css'; -import { ReactComponent as Coffee } from '../../assets/images/coffee.svg'; -import { ReactComponent as Infinity } from '../../assets/images/infinity.svg'; +import FrontCard from '../FrontCard/FrontCard'; -const VoteCard = ({ number, isSelected, isCoffee, isInfinity, onClick }) => { - return ( - - ); +const VoteCard = ({ number, isSelected, onClick, className }) => { + return ( + + ); }; export default VoteCard; diff --git a/src/components/VoteCard/VoteCard.module.css b/src/components/VoteCard/VoteCard.module.css index 9096dba..93088a1 100644 --- a/src/components/VoteCard/VoteCard.module.css +++ b/src/components/VoteCard/VoteCard.module.css @@ -1,52 +1,60 @@ .voteCard { - width: 50px; - height: 75px; - display: inline-block; - padding: .1rem; - margin: 10px; - background-color: #f0f0f0; - cursor: pointer; + width: 50px; + height: 75px; + position: relative; - border: 2px solid #0095ff; - border-radius: .7rem; - transition: border 0.2s ease-in-out, margin 0.2s ease-in-out, background-color 0.2s ease-in-out; - margin-top: 0px; + padding: 0; + margin: 5px 10px; + cursor: pointer; + + border: 0; + background: transparent; } -.voteCardInnerLayer { - border: 2px solid #0095ff; - border-radius: .6rem; - font-size: 26px; - font-weight: 400; - color: #0095ff; - width: calc(100% - 4px); - height: calc(100% - 4px); - display: flex; - align-items: center; - justify-content: center; +.voteCard .frontCard { + transition: + bottom 0.2s ease-in-out, + border 0.2s ease-in-out, + transform 0.2s ease-in-out; + bottom: 0px; } -.voteCard:hover { - background-color: #e0e0e0; - margin-top: -6px; +.voteCard .frontCard:hover { + bottom: 10px; + z-index: 1; } -.selected, .selected:hover { - background-color: #d4edda; - /* border-color: #c3e6cb; */ - margin-top: -12px; -} - -.coffee { - width: 70%; -} - -.infinity { - width: 70%; +.voteCard .frontCard.selected, +.voteCard .frontCard.selected:hover { + bottom: 25px; + transform: scale(1.2); + z-index: 1; } .cardo-regular { - font-family: "Cardo", serif; - font-weight: 400; - font-style: normal; + font-family: 'Cardo', serif; + font-weight: 400; + font-style: normal; +} + +.shadow { + position: absolute; + width: 50px; + height: 10px; + background-color: darkgrey; + bottom: 0; + transition: transform 0.2s ease-in-out; + + border-radius: 50%; + background: radial-gradient(ellipse at center, #00000000, #00000000); +} + +.voteCard:hover .shadow { + background: radial-gradient(ellipse at center, #00000066, #00000000); +} + +.voteCard.selected .shadow, +.voteCard.selected:hover .shadow { + background: radial-gradient(ellipse at center, #00000033, #00000000); + transform: scaleX(1.2); } diff --git a/src/components/VoteCard/VoteCard.stories.js b/src/components/VoteCard/VoteCard.stories.js index cf444fc..1908464 100644 --- a/src/components/VoteCard/VoteCard.stories.js +++ b/src/components/VoteCard/VoteCard.stories.js @@ -2,21 +2,21 @@ import React from 'react'; import VoteCard from './VoteCard'; export default { - title: 'Components/VoteCard', - component: VoteCard, + title: 'Components/VoteCard', + component: VoteCard, }; const Template = (args) => ; export const Default = Template.bind({}); Default.args = { - number: 5, - onClick: () => null, + number: 5, + onClick: () => null, }; export const Selected = Template.bind({}); Selected.args = { - number: 8, - isSelected: true, - onClick: () => null, + number: 8, + isSelected: true, + onClick: () => null, }; diff --git a/src/components/VoteCard/VoteCard.test.js b/src/components/VoteCard/VoteCard.test.js index 8bf673a..7591007 100644 --- a/src/components/VoteCard/VoteCard.test.js +++ b/src/components/VoteCard/VoteCard.test.js @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react'; import VoteCard from './VoteCard'; test('renders VoteCard component', () => { - render(); - const voteElement = screen.getByText(/5/i); - expect(voteElement).toBeInTheDocument(); + render(); + const voteElement = screen.getByText(/5/i); + expect(voteElement).toBeInTheDocument(); }); diff --git a/src/components/VoteValue/VoteValue.jsx b/src/components/VoteValue/VoteValue.jsx new file mode 100644 index 0000000..0bb1fed --- /dev/null +++ b/src/components/VoteValue/VoteValue.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { ReactComponent as Coffee } from '../../assets/images/coffee.svg'; +import { ReactComponent as Infinity } from '../../assets/images/infinity.svg'; +import styles from './VoteValue.module.css'; + +const VoteValue = ({ value, className }) => { + const isInfinity = `${value}` === '9999'; + const isCoffee = `${value}` === '0'; + + return ( +
+ {isCoffee ? ( + + ) : isInfinity ? ( + + ) : ( +
{value >= 0 ? value : ''}
+ )} +
+ ); +}; + +export default VoteValue; diff --git a/src/components/VoteValue/VoteValue.module.css b/src/components/VoteValue/VoteValue.module.css new file mode 100644 index 0000000..9edb699 --- /dev/null +++ b/src/components/VoteValue/VoteValue.module.css @@ -0,0 +1,30 @@ +.voteValueContainer { + display: flex; + align-items: center; + justify-content: center; + width: 100%; /* or specify a specific width */ + height: 100%; /* or specify a specific height */ + padding: 0.25em; +} + +.coffee, +.infinity { + width: 100%; /* Adjust this to the specific size you want */ + height: auto; /* Maintains aspect ratio */ + max-width: 100%; + max-height: 100%; +} + +.value { + font-size: 2rem; /* Adjust this to fit your design */ + font-weight: 800; + line-height: 1; + width: 100%; /* Makes the text conform to the container width */ + height: auto; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + /* text-shadow: none; */ + /* color: #1b191a; */ +} diff --git a/src/components/VotingBoard/VotingBoard.jsx b/src/components/VotingBoard/VotingBoard.jsx index d50941c..abd4003 100644 --- a/src/components/VotingBoard/VotingBoard.jsx +++ b/src/components/VotingBoard/VotingBoard.jsx @@ -1,35 +1,65 @@ import React, { useState } from 'react'; +import { ref, set, get } from 'firebase/database'; +import { realtimeDb } from '../../services/firebase'; import VoteCard from '../VoteCard/VoteCard'; import styles from './VotingBoard.module.css'; -import { submitVote } from '../../services/voteService'; const fibonacciNumbers = [0, 1, 2, 3, 5, 8, 13, 21, 9999]; -const VotingBoard = () => { - const [selectedVote, setSelectedVote] = useState(null); +const VotingBoard = ({ sessionId, userId }) => { + const [selectedVote, setSelectedVote] = useState(null); - const handleVote = (number) => { - setSelectedVote(number); - submitVote("user-id", number); - }; + const handleVote = async (number) => { + setSelectedVote(number); - return ( -
-

Select Your Vote

-
- {fibonacciNumbers.map(num => ( - handleVote(num)} - isSelected={selectedVote === num} - isCoffee={num === 0} - isInfinity={num === 9999} - /> - ))} -
-
+ const userVoteRef = ref( + realtimeDb, + `sessions/${sessionId}/votes/${userId}` ); + + // Get the current vote for this user (if any) + const snapshot = await get(userVoteRef); + const existingVote = + snapshot.exists() && snapshot.val() > -1 ? snapshot.val() : null; + + // Set the new vote for the user + await set(userVoteRef, number); + + const sessionRef = ref(realtimeDb, `sessions/${sessionId}`); + const sessionSnapshot = await get(sessionRef); + + if (sessionSnapshot.exists()) { + const sessionData = sessionSnapshot.val() || {}; + + // Increment votedUsers only if the user hasn't voted before + if (existingVote === null) { + const votedUsers = sessionData.votedUsers || 0; + await set(sessionRef, { + ...sessionData, + votedUsers: votedUsers + 1, + }); + } + } else { + console.error("Session doesn't exist"); + } + }; + + return ( +
+

Select Your Vote

+
+ {fibonacciNumbers.map((num) => ( + handleVote(num)} + isSelected={selectedVote === num} + /> + ))} +
+
+ ); }; export default VotingBoard; diff --git a/src/components/VotingBoard/VotingBoard.module.css b/src/components/VotingBoard/VotingBoard.module.css index 689f851..f2d9274 100644 --- a/src/components/VotingBoard/VotingBoard.module.css +++ b/src/components/VotingBoard/VotingBoard.module.css @@ -1,10 +1,10 @@ .votingBoard { - text-align: center; - padding: 20px; + text-align: center; + padding: 20px; } .voteCards { - display: flex; - justify-content: center; - flex-wrap: wrap; + display: flex; + justify-content: center; + flex-wrap: wrap; } diff --git a/src/components/VotingBoard/VotingBoard.stories.js b/src/components/VotingBoard/VotingBoard.stories.js index ce41440..094ef39 100644 --- a/src/components/VotingBoard/VotingBoard.stories.js +++ b/src/components/VotingBoard/VotingBoard.stories.js @@ -1,9 +1,8 @@ -import React from 'react'; import VotingBoard from './VotingBoard'; export default { - title: 'Components/VotingBoard', - component: VotingBoard, + title: 'Components/VotingBoard', + component: VotingBoard, }; export const Default = () => ; diff --git a/src/components/VotingBoard/VotingBoard.test.js b/src/components/VotingBoard/VotingBoard.test.js index 289489a..65b355a 100644 --- a/src/components/VotingBoard/VotingBoard.test.js +++ b/src/components/VotingBoard/VotingBoard.test.js @@ -2,10 +2,10 @@ import { render, screen, fireEvent } from '@testing-library/react'; import VotingBoard from './VotingBoard'; test('renders voting board and allows selecting a vote', () => { - render(); - const voteButton = screen.getByText(/5/i); - expect(voteButton).toBeInTheDocument(); + render(); + const voteButton = screen.getByText(/5/i); + expect(voteButton).toBeInTheDocument(); - fireEvent.click(voteButton); - expect(voteButton).toHaveClass('selected'); + fireEvent.click(voteButton); + expect(voteButton).toHaveClass('selected'); }); diff --git a/src/components/VotingTable/VotingTable.jsx b/src/components/VotingTable/VotingTable.jsx new file mode 100644 index 0000000..2c04d02 --- /dev/null +++ b/src/components/VotingTable/VotingTable.jsx @@ -0,0 +1,294 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { ref, onValue, get, set, remove } from 'firebase/database'; +import { realtimeDb } from '../../services/firebase'; +import VotingBoard from '../VotingBoard/VotingBoard'; +import BoardCard from '../BoardCard/BoardCard'; +import Results from '../Results/Results'; +import styles from './VotingTable.module.css'; +import titleCardSrc from '../../assets/images/titlecard.png'; +import { ReactComponent as ExitIcon } from '../../assets/images/exit.svg'; +import { useNavigate, useParams } from 'react-router-dom'; +import { auth } from '../../services/firebase'; +import ProfileMenuButton from '../ProfileMenuButton'; + +const VotingTable = ({ userId }) => { + const [users, setUsers] = useState([]); + const [votes, setVotes] = useState({}); + const [, setVotedUsers] = useState(0); + const [allVoted, setAllVoted] = useState(false); + const [isFinal, setIsFinal] = useState(false); + const { sessionId } = useParams(); + const [currentUID, setCurrentUID] = useState(''); + const navigate = useNavigate(); + + const maxUsers = 32; + const username = + users.find((user) => user.uid === currentUID)?.username || ''; + + const goToLobby = useCallback(() => { + navigate(`/lobby/${sessionId}`); + }, [sessionId, navigate]); + + function handleLogout() { + handleDropUser(currentUID); + } + + const handleDropUser = async (userId) => { + const userVoteRef = ref( + realtimeDb, + `sessions/${sessionId}/votes/${userId}` + ); + const userRef = ref(realtimeDb, `sessions/${sessionId}/users/${userId}`); + + await remove(userVoteRef); + await remove(userRef); + + const sessionRef = ref(realtimeDb, `sessions/${sessionId}`); + const snapshot = await get(sessionRef); + + if (snapshot.exists()) { + const sessionData = snapshot.val() || {}; + + if (typeof sessionData.users === 'undefined') { + sessionData.users = {}; + } + if (typeof sessionData.votes === 'undefined') { + sessionData.votes = {}; + } + + const userList = Object.entries(sessionData.users).reduce( + (res, [uid, userInfo]) => { + res[uid] = { + uid, + username: userInfo.username, + }; + return res; + }, + {} + ); + + if (sessionData.votes && sessionData.votes[userId]) { + delete sessionData.votes[userId]; + } + + // Update the session data + await set(sessionRef, { + ...sessionData, + votedUsers: Object.entries(sessionData.votes).reduce( + (res, [uid, vote]) => (userId !== uid && vote >= 0 ? res + 1 : res), + 0 + ), + users: userList, + }); + + // Redirect the dropped user to the lobby + const userLobbyRef = ref(realtimeDb, `users/${userId}`); + await set(userLobbyRef, { + currentSessionId: `lobby/${sessionId}`, + }); + } + }; + + const handleFinalizeVoting = async () => { + const sessionRef = ref(realtimeDb, `sessions/${sessionId}`); + const snapshot = await get(sessionRef); + if (snapshot.exists()) { + const sessionData = snapshot.val() || {}; + + await set(sessionRef, { + ...sessionData, + isFinal: true, + }); + setIsFinal(true); + } + }; + + const handleResetVoting = async () => { + const sessionRef = ref(realtimeDb, `sessions/${sessionId}`); + + // Get the current session data + const snapshot = await get(sessionRef); + if (snapshot.exists()) { + const sessionData = snapshot.val() || {}; + + // Reset the votes while retaining users + const resetVotes = Object.keys(sessionData.votes).reduce((acc, user) => { + acc[user] = -1; + return acc; + }, {}); + + // Update the session in the database with reset votes and reset isFinal + await set(sessionRef, { + ...sessionData, + votes: resetVotes, + votedUsers: 0, + isFinal: false, + }); + + // Update the component state + setVotes(resetVotes); + setVotedUsers(0); + setIsFinal(false); + setAllVoted(false); + } + }; + + useEffect(() => { + const user = auth.currentUser; + + if (!user) { + goToLobby(); + return; + } + + setCurrentUID(user.uid); + + const sessionRef = ref(realtimeDb, `sessions/${sessionId}`); + + get(sessionRef).then((snapshot) => { + if (!snapshot.exists()) { + // Initialize the session if it doesn't exist + set(sessionRef, { + votes: {}, + maxUsers: maxUsers, + votedUsers: 0, + isFinal: false, + users: {}, + }); + } else { + // If the session exists, ensure all necessary fields are set + const sessionData = snapshot.val() || {}; + + // Check and add missing fields + if (!sessionData.maxUsers) { + sessionData.maxUsers = maxUsers; + } + if (!sessionData.votedUsers) { + sessionData.votedUsers = 0; + } + if (!sessionData.isFinal) { + sessionData.isFinal = false; + } + if (typeof sessionData.votes === 'undefined') { + sessionData.votes = {}; + } + if (typeof sessionData.users === 'undefined') { + sessionData.users = {}; + } + + // Update the session with missing fields if needed + set(sessionRef, sessionData); + + const userList = Object.entries(sessionData.users).map( + ([uid, userInfo]) => ({ + uid, + username: userInfo.username, // Ensure username is passed + }) + ); + + // Load the data into the state + setUsers(userList); + setVotes(sessionData.votes); + setVotedUsers(sessionData.votedUsers); + setIsFinal(sessionData.isFinal); + } + }); + + // Listen for changes to the session data + const unsubscribe = onValue(sessionRef, (snapshot) => { + const sessionData = snapshot.val(); + if (!sessionData) return; + + if (typeof sessionData.users === 'undefined') { + sessionData.users = {}; + } + if (typeof sessionData.votes === 'undefined') { + sessionData.votes = {}; + } + + const userList = Object.entries(sessionData.users).map( + ([uid, userInfo]) => ({ + uid, + username: userInfo.username, + }) + ); + + setUsers(userList); + setVotes(sessionData.votes || {}); + setVotedUsers(sessionData.votedUsers || 0); + setIsFinal(sessionData.isFinal || false); + + // Check if all users have voted + setAllVoted( + sessionData.votedUsers === + Object.keys(sessionData.votes || {}).length && + Object.keys(sessionData.votes || {}).length > 0 + ); + }); + + return () => { + unsubscribe(); + }; + }, [sessionId, maxUsers, navigate, setCurrentUID, goToLobby]); + + return ( +
+ {/* handleDropUser(currentUID)} + title="Logout" + /> */} + + round-table +
+ {users.map(({ uid, username }) => ( +
+ + { + handleDropUser(uid)} + title="Drop User" + /> + } +
+ ))} +
+
+ {allVoted && !isFinal && ( +
+ Reveal Votes +
+ )} + {isFinal && ( +
+ Restart Voting +
+ )} +
+ {!isFinal ? ( + + ) : ( + + )} +
+ ); +}; + +export default VotingTable; diff --git a/src/components/VotingTable/VotingTable.module.css b/src/components/VotingTable/VotingTable.module.css new file mode 100644 index 0000000..fc3e704 --- /dev/null +++ b/src/components/VotingTable/VotingTable.module.css @@ -0,0 +1,125 @@ +.votingTable { + text-align: center; + padding: 20px; +} + +.votingTable .logout { + position: absolute; + right: 10px; + top: 10px; + color: #f0dcb1; + cursor: pointer; + width: 24px; + height: 24px; + padding: 6px; + border-radius: 50%; + user-select: none; + border: 1px solid #f0dcb1; + opacity: 0.5; +} + +.votingTable .logout:hover { + color: #ddcba3; + border-color: #ddcba3; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 1); + opacity: 1; +} + +.votingTable .logout:active { + transform: scale(0.95); + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5); +} + +.votingTable .reveal { + margin-top: 8px; +} + +.votingTable .restart { + margin-top: 8px; +} + +.votingTable .titleCard { + max-width: 400px; + width: calc(100% - 150px); +} + +.votingTable .tableCards { + display: flex; + align-items: center; + width: 100%; + justify-content: center; + margin-top: 8px; + flex-wrap: wrap; +} + +.votingTable .tableCards .userCardWrapper { + position: relative; + display: inline-block; + height: 155px; + overflow: hidden; +} + +.dropButton { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, 0%); + display: none; + background-color: #f0dcb1; + color: #1b191a; + border: 1px solid #1b191a; + padding: 5px; + cursor: pointer; + width: 20px; + height: 20px; + border-radius: 50%; + user-select: none; + transition: background-color 0.3s ease; +} + +.userCardWrapper:hover .dropButton { + display: block; + box-shadow: 2px 2px 8px rgba(0, 0, 0, 1); +} + +.dropButton:hover { + background-color: #ddcba3; +} + +.dropButton:active { + background-color: #ddcba3; +} + +.userCardWrapper:hover .dropButton:active { + transform: translate(-50%, 0%) scale(0.95); + box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.5); +} + +.votingTable .form { + display: flex; + justify-content: center; +} + +.votingTable .form .button { + background-color: #f0dcb1; + border: 1px solid #1b191a; + border-radius: 4px; + color: #1b191a; + text-shadow: none; + padding: 4px; + cursor: pointer; + font-weight: 800; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 1); + transition: background-color 0.3s ease; +} + +.votingTable .form .button:hover { + background-color: #cdba95; + box-shadow: 2px 2px 5px rgba(0, 0, 0, 1); +} + +.votingTable .form .button:active { + background-color: #f0dcb1; + transform: scale(0.95); + box-shadow: 1px 1px 5px rgba(0, 0, 0, 0.5); +} diff --git a/src/components/VotingTable/VotingTable.stories.js b/src/components/VotingTable/VotingTable.stories.js new file mode 100644 index 0000000..77b56ad --- /dev/null +++ b/src/components/VotingTable/VotingTable.stories.js @@ -0,0 +1,25 @@ +import React from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import VotingTable from './VotingTable'; +import { generateUniqueId } from '../../utils/crypto'; + +export default { + title: 'Components/VotingTable', + component: VotingTable, +}; + +export const Default = () => { + const mockSessionId = generateUniqueId(); + const mockUsername = 'testUser'; + + return ( + + + } + /> + + + ); +}; diff --git a/src/components/VotingTable/VotingTable.test.js b/src/components/VotingTable/VotingTable.test.js new file mode 100644 index 0000000..552469d --- /dev/null +++ b/src/components/VotingTable/VotingTable.test.js @@ -0,0 +1,8 @@ +import { render, screen } from '@testing-library/react'; +import Lobby from './VotingTable'; + +test('renders voting Lobby', () => { + const mockOnJoinSession = jest.fn(); + render(); + expect(screen.getByText(/5: 2 vote\(s\)/i)).toBeInTheDocument(); +}); diff --git a/src/hooks/useVote.js b/src/hooks/useVote.js index c15d2eb..fed5421 100644 --- a/src/hooks/useVote.js +++ b/src/hooks/useVote.js @@ -2,15 +2,15 @@ import { useState, useEffect } from 'react'; import { fetchVotes } from '../services/voteService'; export const useVotes = () => { - const [votes, setVotes] = useState([]); + const [votes, setVotes] = useState([]); - useEffect(() => { - fetchVotes().then(snapshot => { - if (snapshot.exists()) { - setVotes(snapshot.val()); - } - }); - }, []); + useEffect(() => { + fetchVotes().then((snapshot) => { + if (snapshot.exists()) { + setVotes(snapshot.val()); + } + }); + }, []); - return votes; + return votes; }; diff --git a/src/index.css b/src/index.css index ec2585e..cdd7689 100644 --- a/src/index.css +++ b/src/index.css @@ -1,10 +1,63 @@ +html { + height: 100%; +} + body { margin: 0; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + font-family: + 'cinzel', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + 'Roboto', + 'Oxygen', + 'Ubuntu', + 'Cantarell', + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + color: #f0dcb1; + text-shadow: 2px 2px #1b191a; + background: radial-gradient(ellipse at center, #1c4926ff 50%, #0d2412 100%); + height: 100%; +} + +input { + width: 150px; +} + +input, +select, +textarea { + border: 1px solid #1b191a; +} + +input:focus, +select:focus, +textarea:focus, +button:focus { + outline-color: #1b191a; + outline: none; + /* border: 1px solid #1b191a; */ +} + +button { + font-family: + 'cinzel', + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + 'Roboto', + 'Oxygen', + 'Ubuntu', + 'Cantarell', + 'Fira Sans', + 'Droid Sans', + 'Helvetica Neue', + sans-serif; } code { diff --git a/src/index.jsx b/src/index.jsx index 6b895ae..37434ca 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; +import './assets/fonts/fonts.css'; import '@fontsource/cardo/400.css'; const root = ReactDOM.createRoot(document.getElementById('root')); diff --git a/src/reportWebVitals.js b/src/reportWebVitals.js index 5253d3a..532f29b 100644 --- a/src/reportWebVitals.js +++ b/src/reportWebVitals.js @@ -1,4 +1,4 @@ -const reportWebVitals = onPerfEntry => { +const reportWebVitals = (onPerfEntry) => { if (onPerfEntry && onPerfEntry instanceof Function) { import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { getCLS(onPerfEntry); diff --git a/src/services/firebase-mock.js b/src/services/firebase-mock.js new file mode 100644 index 0000000..080d0b5 --- /dev/null +++ b/src/services/firebase-mock.js @@ -0,0 +1,64 @@ +// src/services/firebase-mock.js + +export const initializeApp = () => + console.log('initializeApp was called!!!!!!') || {}; + +// Mock Firestore +export const getFirestore = () => + console.log('getFirestore was called!!!!!!') || { + collection: () => ({ + doc: () => ({ + get: () => Promise.resolve({ data: () => ({}) }), + set: () => Promise.resolve(), + }), + }), + }; + +// Mock Auth +export const getAuth = () => + console.log('getAuth was called!!!!!!') || { + signInAnonymously: () => Promise.resolve({}), + }; + +// Mock Realtime Database +export const getDatabase = () => + console.log('getDatabase was called!!!!!!') || { + ref: () => { + console.log('getDatabase REF was called!!!!!!'); + return { + on: (eventType, callback) => { + // Mock implementation of 'on' + console.log(`Mock on called with event type: ${eventType}`); + }, + off: () => { + // Mock implementation of 'off' + console.log('Mock off called to remove listeners'); + }, + set: (value) => { + console.log(`Mock set called with value: ${JSON.stringify(value)}`); + return Promise.resolve(); + }, + once: (eventType) => { + console.log(`Mock once called with event type: ${eventType}`); + return Promise.resolve({ val: () => ({}) }); + }, + }; + }, + }; + +// Mock Analytics +export const getAnalytics = () => + console.log('getAnalytics was called!!!!!!') || { + logEvent: (eventName, eventParams) => { + console.log(`Mock logEvent called with event: ${eventName}`, eventParams); + }, + setCurrentScreen: (screenName) => { + console.log(`Mock setCurrentScreen called with screen: ${screenName}`); + }, + setUserId: (userId) => { + console.log(`Mock setUserId called with user ID: ${userId}`); + }, + setUserProperties: (properties) => { + console.log(`Mock setUserProperties called with properties:`, properties); + }, + }; diff --git a/src/services/firebase.js b/src/services/firebase.js index 5d5ab2f..cb47fa9 100644 --- a/src/services/firebase.js +++ b/src/services/firebase.js @@ -1,19 +1,45 @@ -import { initializeApp } from "firebase/app"; -import { getFirestore } from "firebase/firestore"; -import { getAuth } from "firebase/auth"; -import { getDatabase } from "firebase/database"; +import { initializeApp } from 'firebase/app'; +import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore'; +import { getAuth, connectAuthEmulator } from 'firebase/auth'; +import { getDatabase, connectDatabaseEmulator } from 'firebase/database'; +import { getAnalytics } from 'firebase/analytics'; const firebaseConfig = { - apiKey: "YOUR_API_KEY", - authDomain: "YOUR_PROJECT_ID.firebaseapp.com", - projectId: "YOUR_PROJECT_ID", - storageBucket: "YOUR_PROJECT_ID.appspot.com", - messagingSenderId: "YOUR_MESSAGING_SENDER_ID", - appId: "YOUR_APP_ID", - databaseURL: "YOUR_DATABASE_URL" + apiKey: process.env.REACT_APP_FIREBASE_API_KEY, + authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN, + projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID, + storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET, + messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID, + appId: process.env.REACT_APP_FIREBASE_APP_ID, + measurementId: process.env.REACT_APP_FIREBASE_MEASUREMENT_ID, + databaseURL: process.env.REACT_APP_FIREBASE_DATABASE_URL, }; +// Initialize Firebase const app = initializeApp(firebaseConfig); + +// Initialize Firebase services export const db = getFirestore(app); export const auth = getAuth(app); export const realtimeDb = getDatabase(app); + +export let analytics; + +// Detect if running in Firebase Hosting Emulator +const isEmulator = + window.location.hostname === 'localhost' && window.location.port === '5000'; + +if (process.env.NODE_ENV === 'development' || isEmulator) { + // Connect to Firebase Emulators in development environment (including Storybook) + connectFirestoreEmulator(db, 'localhost', 8080); + connectAuthEmulator(auth, 'http://localhost:9099'); + connectDatabaseEmulator(realtimeDb, 'localhost', 9000); + + analytics = { + logEvent: (eventName, eventParams) => { + console.log(`Analytics event: ${eventName}`, eventParams); + }, + }; +} else { + analytics = getAnalytics(app); +} diff --git a/src/services/voteService.js b/src/services/voteService.js index 38a40c4..79a4595 100644 --- a/src/services/voteService.js +++ b/src/services/voteService.js @@ -1,11 +1,11 @@ import { ref, set, get } from 'firebase/database'; -import { realtimeDb } from './firebase'; +import { realtimeDb } from '../__mocks__/firebase'; export const submitVote = (userId, voteValue) => { - return set(ref(realtimeDb, `votes/${userId}`), voteValue); + return set(ref(realtimeDb, `votes/${userId}`), voteValue); }; export const fetchVotes = () => { - const votesRef = ref(realtimeDb, 'votes/'); - return get(votesRef); + const votesRef = ref(realtimeDb, 'votes/'); + return get(votesRef); }; diff --git a/src/stories/Button.jsx b/src/stories/Button.jsx index 15dde39..9f2f6bc 100644 --- a/src/stories/Button.jsx +++ b/src/stories/Button.jsx @@ -6,11 +6,15 @@ import './button.css'; * Primary UI component for user interaction */ export const Button = ({ primary, backgroundColor, size, label, ...props }) => { - const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary'; + const mode = primary + ? 'storybook-button--primary' + : 'storybook-button--secondary'; return (