diff --git a/package-lock.json b/package-lock.json index 89f4e0cd..6f963d34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@testing-library/user-event": "13.5.0", "react": "18.2.0", "react-dom": "18.2.0", + "react-router-dom": "^6.16.0", "react-scripts": "5.0.1", "web-vitals": "2.1.4" } @@ -4530,6 +4531,14 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@remix-run/router": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.9.0.tgz", + "integrity": "sha512-bV63itrKBC0zdT27qYm6SDZHlkXwFL1xMBuhkn+X7l0+IIhNaH5wuuvZKp6eKhCD4KFhujhfhCT1YxXW6esUIA==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -17275,6 +17284,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.16.0.tgz", + "integrity": "sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==", + "dependencies": { + "@remix-run/router": "1.9.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.16.0.tgz", + "integrity": "sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==", + "dependencies": { + "@remix-run/router": "1.9.0", + "react-router": "6.16.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", diff --git a/package.json b/package.json index ca2a9136..90494dce 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@testing-library/user-event": "13.5.0", "react": "18.2.0", "react-dom": "18.2.0", + "react-router-dom": "^6.16.0", "react-scripts": "5.0.1", "web-vitals": "2.1.4" }, diff --git a/src/App.js b/src/App.js index 96d24665..c93cb0ab 100644 --- a/src/App.js +++ b/src/App.js @@ -1,98 +1,119 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useState } from "react"; +import { createBrowserRouter, RouterProvider } from "react-router-dom"; -import CssBaseline from '@mui/material/CssBaseline' +import Login from "./pages/login"; + +import CssBaseline from "@mui/material/CssBaseline"; import { createTheme, ThemeProvider, responsiveFontSizes, -} from '@mui/material/styles' - -import Stack from '@mui/material/Stack' +} from "@mui/material/styles"; -import Content from './components/Content' -import Loading from './components/Loading' +import Stack from "@mui/material/Stack"; +import Content from "./components/Content"; +import Loading from "./components/Loading"; +import ShoppingList from "./pages/shoppingList"; let theme = createTheme({ palette: { primary: { - main: '#de6720', + main: "#de6720", }, secondary: { - main: '#0077ea', + main: "#0077ea", }, }, components: { MuiCssBaseline: { styleOverrides: { html: { - WebkitFontSmoothing: 'auto', + WebkitFontSmoothing: "auto", }, body: { - background: '#fefefe', - overflow: 'hidden', - padding: '5px', - width: '450px', - color: '#333', + background: "#fefefe", + overflow: "hidden", + padding: "5px", + width: "100vw", + height: "100vh", + color: "#333", }, }, }, MuiLinearProgress: { styleOverrides: { root: { - backgroundColor: 'rgba(222, 103, 32, 0.25)', + backgroundColor: "rgba(222, 103, 32, 0.25)", }, bar: { - backgroundColor: 'rgba(222, 103, 32, 1)', + backgroundColor: "rgba(222, 103, 32, 1)", + }, + }, + }, + MuiTableHead: { + styleOverrides: { + root: { + backgroundColor: "#f5f5f5", + fontWeight: "600", + }, + }, + }, + MuiTableCell: { + styleOverrides: { + root: { + fontSize: "1.2rem", + }, + }, + }, + MuiPopover: { + styleOverrides: { + paper: { + minWidth: "80vw", + minHeight: "50vh", }, }, }, }, -}) -theme = responsiveFontSizes(theme) +}); +theme = responsiveFontSizes(theme); +const router = createBrowserRouter([ + { + path: "/", + element: , + }, + { + path: "/login", + element: , + }, +]); const App = () => { - - const [loading, setLoading] = useState(false) + const [loading, setLoading] = useState(false); useEffect(() => { - // show a loading indicator - setLoading(true) + setLoading(true); setTimeout(() => { - // hide loading indicator - setLoading(false) - }, 1000) // 1 second + setLoading(false); + }, 1000); // 1 second // hide loading indicator return () => { - setLoading(false) - } - }, []) - - const renderLoading = () => { - if ( !loading ) { return } - return - } - - const renderContent = () => { - if ( loading ) { return } - return - } + setLoading(false); + }; + }, []); return ( - - {renderLoading()} - {renderContent()} - + - ) -} + ); +}; -export default App +export default App; diff --git a/src/api/products.js b/src/api/products.js new file mode 100644 index 00000000..aebdce9c --- /dev/null +++ b/src/api/products.js @@ -0,0 +1,14 @@ +const API_URL = "https://dummyjson.com/products"; + +export const ProductsAPI = { + getAllProducts: async () => { + const response = await fetch(API_URL); + const data = await response.json(); + return data; + }, + searchProducts: async (query) => { + const response = await fetch(`${API_URL}/search?q=${query}`); + const data = await response.json(); + return data; + } +}; diff --git a/src/pages/login.jsx b/src/pages/login.jsx new file mode 100644 index 00000000..526d0bcd --- /dev/null +++ b/src/pages/login.jsx @@ -0,0 +1,113 @@ +import * as React from 'react'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import CssBaseline from '@mui/material/CssBaseline'; +import TextField from '@mui/material/TextField'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Link from '@mui/material/Link'; +import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; +import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; +import Typography from '@mui/material/Typography'; +import Container from '@mui/material/Container'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; + +function Copyright(props) { + return ( + + {'Copyright © '} + + Your Website + {' '} + {new Date().getFullYear()} + {'.'} + + ); +} + +// TODO remove, this demo shouldn't need to reset the theme. + +const defaultTheme = createTheme(); + +export default function SignIn() { + const handleSubmit = (event) => { + event.preventDefault(); + const data = new FormData(event.currentTarget); + const user = { + email: data.get('email'), + password: data.get('password'), + }; + + }; + + return ( + + + + + + + + + Sign in + + + + + } + label="Remember me" + /> + + + + + Forgot password? + + + + + {"Don't have an account? Sign Up"} + + + + + + + + + ); +} \ No newline at end of file diff --git a/src/pages/shoppingList.jsx b/src/pages/shoppingList.jsx new file mode 100644 index 00000000..fc43dedc --- /dev/null +++ b/src/pages/shoppingList.jsx @@ -0,0 +1,325 @@ +import React, { useState } from "react"; +import { + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, + TextField, + Typography, + Popover, + Box, +} from "@mui/material"; +import { ProductsAPI } from "../api/products"; +import { initializeSpeech, startRecognition } from "../utils/speechRecognition"; + +function ShoppingList() { + const initialItems = [ + { + id: -1, + title: "Apples", + price: 2.99, + thumbnail: "apple.jpg", + quantity: 3, + }, + { + id: 2, + title: "Bananas", + price: 1.99, + thumbnail: "banana.jpg", + quantity: 2, + }, + ]; + + const [searchTerm, setSearchTerm] = useState(""); + const [results, setResults] = useState([]); + const [anchorEl, setAnchorEl] = useState(null); + const [items, setItems] = useState(initialItems); + const [listening, setListening] = useState(false); + const [recognition, setRecognition] = useState(null); + + const searchItems = async () => { + const res = await ProductsAPI.searchProducts(searchTerm); + const products = res.products.map((product) => ({ + ...product, + quantity: 1, + })); + setResults(products); + }; + + const removeItem = (id) => { + const updatedItems = items.filter((item) => item.id !== id); + setItems(updatedItems); + }; + + const openPopover = (event) => { + setAnchorEl(event.currentTarget); + }; + + const closePopover = () => { + setAnchorEl(null); + }; + + const startSpeechRecognition = () => { + const speechSearch = (term) => { + setSearchTerm(term); + searchItems(); + }; + + const toggleListening = () => { + setListening((listening) => !listening); + }; + + const recognition = initializeSpeech(); + setRecognition(recognition); + startRecognition( + recognition, + toggleListening, + speechSearch, + toggleListening + ); + }; + + const stopSpeechRecognition = () => { + recognition.stop(); + setRecognition(null); + setListening(false); + }; + + const isPopoverOpen = Boolean(anchorEl); + + const listToText = () => { + return ( + "Shopping List: \n Item \t Price \t Quantity \t Total\n" + + items + .map( + (item) => + `${item.title.padEnd(" ", 20)} \t $${item.price.toFixed(2)} \t ${ + item.quantity + } \t $${(item.price * item.quantity).toFixed(2)}` + ) + .join("\n") + ); + }; + + const downloadList = () => { + const element = document.createElement("a"); + + const text = listToText(); + + element.setAttribute( + "href", + "data:text/plain;charset=utf-8," + encodeURIComponent(text) + ); + element.setAttribute("download", "ShoppingList.txt"); + + element.style.display = "none"; + document.body.appendChild(element); + + element.click(); + + document.body.removeChild(element); + }; + + const ItemsPopover = ( + + + + + setSearchTerm(e.target.value)} + style={{ width: "50%" }} + /> + + + + + + + + Item + Price + Image + Quantity + Actions + + + + {results.map((item) => ( + + {item.title} + ${item.price.toFixed(2)} + + {item.title} + + + { + const updatedItem = { + ...item, + quantity: e.target.value, + }; + const updatedResults = results.map((result) => + result.id === item.id ? updatedItem : result + ); + setResults(updatedResults); + }} + /> + + + + + + ))} + +
+
+
+
+ ); + + const ShoppingTable = ( + + + + + Item + Price + Image + Quantity + Total Price + Actions + + + + {items.map((item) => ( + + {item.title} + ${item.price.toFixed(2)} + + {item.title} + + {item.quantity} + ${(item.price * item.quantity).toFixed(2)} + + + + + ))} + +
+
+ ); + + return ( +
+ Shopping List + + {ItemsPopover} + {ShoppingTable} +
+ {/* Download List Button */} + + {/* Share Via Email */} + + + +
+
+ ); +} + +export default ShoppingList; diff --git a/src/utils/speechRecognition.js b/src/utils/speechRecognition.js new file mode 100644 index 00000000..abb7ac15 --- /dev/null +++ b/src/utils/speechRecognition.js @@ -0,0 +1,81 @@ +const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; +const SpeechGrammarList = window.SpeechGrammarList || window.webkitSpeechGrammarList; + +export const initializeSpeech = () => { + const recognition = new SpeechRecognition(); + const speechRecognitionList = new SpeechGrammarList(); + recognition.grammars = speechRecognitionList; + recognition.lang = 'en-US'; + recognition.interimResults = false; + recognition.maxAlternatives = 1; + return recognition; +} + +export const startRecognition = (recognition, onStart, onResult, onEnd) => { + + // To ensure case consistency while checking with the returned output text + recognition.start(); + onStart(); + + recognition.onresult = function(event) { + // The SpeechRecognitionEvent results property returns a SpeechRecognitionResultList object + // The SpeechRecognitionResultList object contains SpeechRecognitionResult objects. + // It has a getter so it can be accessed like an array + // The first [0] returns the SpeechRecognitionResult at position 0. + // Each SpeechRecognitionResult object contains SpeechRecognitionAlternative objects that contain individual results. + // These also have getters so they can be accessed like arrays. + // The second [0] returns the SpeechRecognitionAlternative at position 0. + // We then return the transcript property of the SpeechRecognitionAlternative object + const speechResult = event.results[0][0].transcript.toLowerCase(); + onResult(speechResult); + console.log('Confidence: ' + event.results[0][0].confidence); + } + + recognition.onspeechend = function() { + recognition.stop(); + onEnd(); + } + + recognition.onerror = function(event) { + console.log('Error occurred in recognition: ' + event.error); + } + + recognition.onaudiostart = function(event) { + //Fired when the user agent has started to capture audio. + console.log('Listening'); + } + + recognition.onaudioend = function(event) { + //Fired when the user agent has finished capturing audio. + console.log('Done listening'); + } + + recognition.onend = function(event) { + //Fired when the speech recognition service has disconnected. + console.log('SpeechRecognition.onend'); + } + + recognition.onnomatch = function(event) { + //Fired when the speech recognition service returns a final result with no significant recognition. This may involve some degree of recognition, which doesn't meet or exceed the confidence threshold. + console.log('Please try again.'); + } + + recognition.onsoundstart = function(event) { + //Fired when any sound — recognisable speech or not — has been detected. + console.log('SpeechRecognition.onsoundstart'); + } + + recognition.onsoundend = function(event) { + //Fired when any sound — recognisable speech or not — has stopped being detected. + console.log('SpeechRecognition.onsoundend'); + } + + recognition.onspeechstart = function (event) { + //Fired when sound that is recognised by the speech recognition service as speech has been detected. + console.log('SpeechRecognition.onspeechstart'); + } + recognition.onstart = function(event) { + //Fired when the speech recognition service has begun listening to incoming audio with intent to recognize grammars associated with the current SpeechRecognition. + console.log('SpeechRecognition.onstart'); + } +} \ No newline at end of file diff --git a/src/utils/utils.js b/src/utils/utils.js new file mode 100644 index 00000000..79ce59a6 --- /dev/null +++ b/src/utils/utils.js @@ -0,0 +1,4 @@ +// save data to local storage +export const saveToLocalStorage = (key, value) => { + localStorage.setItem(key, JSON.stringify(value)); +}; \ No newline at end of file