diff --git a/package-lock.json b/package-lock.json index a25baa5..6739593 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { - "name": "goit-fs91-react-hw-03-phonebook", + "name": "goit-fs91-react-hw-04-phonebook", "version": "0.1.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "goit-fs91-react-hw-03-phonebook", + "name": "goit-fs91-react-hw-04-phonebook", "version": "0.1.0", "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.18", "@mui/material": "^5.14.18", + "@reduxjs/toolkit": "^2.0.1", "@testing-library/jest-dom": "^5.16.3", "@testing-library/react": "^12.1.4", "@testing-library/user-event": "^13.5.0", @@ -20,8 +21,10 @@ "react": "^18.1.0", "react-dom": "^18.1.0", "react-icons": "^4.11.0", + "react-redux": "^9.0.4", "react-scripts": "5.0.1", "react-toastify": "^9.1.3", + "redux-persist": "^6.0.0", "shortid": "^2.2.16", "web-vitals": "^2.1.3" }, @@ -3114,6 +3117,38 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.0.1.tgz", + "integrity": "sha512-fxIjrR9934cmS8YXIGd9e7s1XRsEU++aFc9DVNMFMRTM5Vtsg2DCRMj21eslGtDt43IUf9bJL3h5bwUlZleibA==", + "dependencies": { + "immer": "^10.0.3", + "redux": "^5.0.0", + "redux-thunk": "^3.1.0", + "reselect": "^5.0.1" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz", + "integrity": "sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -3748,9 +3783,9 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, "node_modules/@types/react": { - "version": "17.0.43", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.43.tgz", - "integrity": "sha512-8Q+LNpdxf057brvPu1lMtC5Vn7J119xrP1aq4qiaefNioQUYANF/CYeK4NsKorSZyUGJ66g0IM+4bbjwx45o2A==", + "version": "18.2.43", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.43.tgz", + "integrity": "sha512-nvOV01ZdBdd/KW6FahSbcNplt2jCJfyWdTos61RYHV+FVv5L/g9AOX1bmbVcWcLFL8+KHQfh1zVIQrud6ihyQA==", "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -3834,6 +3869,11 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" + }, "node_modules/@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", @@ -12203,6 +12243,32 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-redux": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.0.4.tgz", + "integrity": "sha512-9J1xh8sWO0vYq2sCxK2My/QO7MzUMRi3rpiILP/+tDr8krBHixC6JMM17fMK88+Oh3e4Ae6/sHIhNBgkUivwFA==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.3", + "use-sync-external-store": "^1.0.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25", + "react": "^18.0", + "react-native": ">=0.69", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react-native": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -12357,6 +12423,27 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.0.tgz", + "integrity": "sha512-blLIYmYetpZMET6Q6uCY7Jtl/Im5OBldy+vNPauA8vvsdqyt66oep4EUpAMWNHauTC6xa9JuRPhRB72rY82QGA==" + }, + "node_modules/redux-persist": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", + "peerDependencies": { + "redux": ">4.0.0" + } + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -12499,6 +12586,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, + "node_modules/reselect": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.0.1.tgz", + "integrity": "sha512-D72j2ubjgHpvuCiORWkOUxndHJrxDaSolheiz5CO+roz8ka97/4msh2E8F5qay4GawR5vzBt5MkbDHT+Rdy/Wg==" + }, "node_modules/resolve": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", @@ -14026,6 +14118,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -17077,6 +17177,24 @@ "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==" }, + "@reduxjs/toolkit": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.0.1.tgz", + "integrity": "sha512-fxIjrR9934cmS8YXIGd9e7s1XRsEU++aFc9DVNMFMRTM5Vtsg2DCRMj21eslGtDt43IUf9bJL3h5bwUlZleibA==", + "requires": { + "immer": "^10.0.3", + "redux": "^5.0.0", + "redux-thunk": "^3.1.0", + "reselect": "^5.0.1" + }, + "dependencies": { + "immer": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.0.3.tgz", + "integrity": "sha512-pwupu3eWfouuaowscykeckFmVTpqbzW+rXFCX8rQLkZzM9ftBmU/++Ra+o+L27mz03zJTlyV4UUr+fdKNffo4A==" + } + } + }, "@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -17558,9 +17676,9 @@ "integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==" }, "@types/react": { - "version": "17.0.43", - "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.43.tgz", - "integrity": "sha512-8Q+LNpdxf057brvPu1lMtC5Vn7J119xrP1aq4qiaefNioQUYANF/CYeK4NsKorSZyUGJ66g0IM+4bbjwx45o2A==", + "version": "18.2.43", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.43.tgz", + "integrity": "sha512-nvOV01ZdBdd/KW6FahSbcNplt2jCJfyWdTos61RYHV+FVv5L/g9AOX1bmbVcWcLFL8+KHQfh1zVIQrud6ihyQA==", "requires": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -17644,6 +17762,11 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz", "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg==" }, + "@types/use-sync-external-store": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==" + }, "@types/ws": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.3.tgz", @@ -23601,6 +23724,15 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "react-redux": { + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.0.4.tgz", + "integrity": "sha512-9J1xh8sWO0vYq2sCxK2My/QO7MzUMRi3rpiILP/+tDr8krBHixC6JMM17fMK88+Oh3e4Ae6/sHIhNBgkUivwFA==", + "requires": { + "@types/use-sync-external-store": "^0.0.3", + "use-sync-external-store": "^1.0.0" + } + }, "react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -23715,6 +23847,23 @@ "strip-indent": "^3.0.0" } }, + "redux": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.0.tgz", + "integrity": "sha512-blLIYmYetpZMET6Q6uCY7Jtl/Im5OBldy+vNPauA8vvsdqyt66oep4EUpAMWNHauTC6xa9JuRPhRB72rY82QGA==" + }, + "redux-persist": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/redux-persist/-/redux-persist-6.0.0.tgz", + "integrity": "sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==", + "requires": {} + }, + "redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "requires": {} + }, "regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -23826,6 +23975,11 @@ "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "integrity": "sha1-kl0mAdOaxIXgkc8NpcbmlNw9yv8=" }, + "reselect": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.0.1.tgz", + "integrity": "sha512-D72j2ubjgHpvuCiORWkOUxndHJrxDaSolheiz5CO+roz8ka97/4msh2E8F5qay4GawR5vzBt5MkbDHT+Rdy/Wg==" + }, "resolve": { "version": "1.22.0", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz", @@ -24955,6 +25109,12 @@ "requires-port": "^1.0.0" } }, + "use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "requires": {} + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 14756cb..0e2e668 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,14 @@ { - "name": "goit-fs91-react-hw-04-phonebook", + "name": "goit-fs91-react-hw-06-phonebook", "version": "0.1.0", "private": true, - "homepage": "https://kharchenkok.github.io/goit-fs91-react-hw-04-phonebook/", + "homepage": "https://kharchenkok.github.io/goit-fs91-react-hw-06-phonebook/", "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.18", "@mui/material": "^5.14.18", + "@reduxjs/toolkit": "^2.0.1", "@testing-library/jest-dom": "^5.16.3", "@testing-library/react": "^12.1.4", "@testing-library/user-event": "^13.5.0", @@ -16,8 +17,10 @@ "react": "^18.1.0", "react-dom": "^18.1.0", "react-icons": "^4.11.0", + "react-redux": "^9.0.4", "react-scripts": "5.0.1", "react-toastify": "^9.1.3", + "redux-persist": "^6.0.0", "shortid": "^2.2.16", "web-vitals": "^2.1.3" }, diff --git a/src/App.js b/src/App.js index eb45455..bbd3941 100644 --- a/src/App.js +++ b/src/App.js @@ -1,86 +1,41 @@ -import { useState } from 'react'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; -import shortid from 'shortid'; + import ContactForm from './components/ContactForm'; import ContactList from './components/ContactList'; -import Filter from './components/Filter'; +import NameFilter from './components/Filter'; import Section from './components/Section'; -import Notification from './components/Notification'; -import useLocalStorage from './hooks/useLocalStorage'; -const initialContacts = [ - { id: 'id-1', name: 'Rosie Simpson', number: '459-12-56' }, - { id: 'id-2', name: 'Hermione Kline', number: '443-89-12' }, - { id: 'id-3', name: 'Eden Clements', number: '645-17-79' }, - { id: 'id-4', name: 'Annie Copeland', number: '227-91-26' }, -]; +import PhoneIcon from './images/phonebook.png'; function App() { - const [contacts, setContacts] = useLocalStorage('contacts', initialContacts); - const [filter, setFilter] = useState(''); - - const addContact = ({ name, number }) => { - const contact = { - id: shortid.generate(), - name, - number, - }; - setContacts(prevContacts => [contact, ...prevContacts]); - }; - - const deleteContact = contactId => { - setContacts(prevContacts => - prevContacts.filter(contact => contact.id !== contactId) - ); - }; - - const changeFilter = e => { - setFilter(e.currentTarget.value); - }; - - const getVisibleContacts = () => { - const normalizedFilter = filter.toLowerCase(); - return contacts.filter(contact => - contact.name.toLowerCase().includes(normalizedFilter) - ); - }; - - const isContactExist = name => { - return contacts.some( - contact => contact.name.toLowerCase() === name.toLowerCase() - ); - }; - - const visibleContacts = getVisibleContacts(); return ( -
+ <> -
- -
-
- {contacts.length > 0 ? ( - <> - - {visibleContacts.length > 0 ? ( - - ) : ( - - )} - - ) : ( - - )} -
-
+
+
+

+ {''} + Phonebook +

+
+
+
+
+
+
+ + +
+ +
+ +
+
+
+
+ ); } diff --git a/src/components/ContactForm/ContactForm.js b/src/components/ContactForm/ContactForm.js index 41fdfbc..2612df7 100644 --- a/src/components/ContactForm/ContactForm.js +++ b/src/components/ContactForm/ContactForm.js @@ -1,193 +1,88 @@ -import React, { useReducer } from 'react'; -import { showError, showWarning } from '../../utils/ToastNotification'; +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { addContactAction } from '../../store/contacts/contactsSlice'; +import { + showError, + showSuccess, + showWarning, +} from '../../utils/ToastNotification'; +import { + colorPickerOptions, + DEFAULT_COLOR, +} from '../../constans/ColorConstans'; +import style from './ContactForm.module.css'; -// ============== useReducer ==================================================================== -const formReducer = (state, action) => { - switch (action.type) { - case 'CHANGE': - return { ...state, [action.field]: action.value }; - case 'RESET': - return { name: '', number: '' }; - default: - return state; - } -}; - -export default function ContactForm({ isContactExist, onFormSubmit }) { - const [formData, dispatch] = useReducer(formReducer, { - name: '', - number: '', - }); - - const handleChange = event => { - const { name, value } = event.target; - dispatch({ type: 'CHANGE', field: name, value }); - }; +export default function ContactForm() { + const dispatch = useDispatch(); + const { contacts } = useSelector(state => state.contacts); const handleSubmit = event => { event.preventDefault(); + const { name, number, color } = event.target; + const existingContact = contacts.find( + contact => contact.name.toLowerCase() === name.value.toLowerCase() + ); - const { name, number } = formData; - - if (name.trim() === '' || number.trim() === '') { + if (!name.value.trim() || !number.value.trim()) { showError('Make sure all fields are completed!'); return; } - if (isContactExist(name)) { - showWarning(`${name} is already in contacts!`); + if (existingContact) { + showWarning(`${name.value} is already in contacts!`); return; } - onFormSubmit({ name, number }); - dispatch({ type: 'RESET' }); + const selectedColor = color.value || DEFAULT_COLOR; + + dispatch( + addContactAction({ + name: name.value, + number: number.value, + color: selectedColor, + }) + ); + showSuccess(`${name.value} added to contacts!`); + + event.target.reset(); }; return ( -
- - + + - + + +
+ {colorPickerOptions.map(({ label, color }) => ( + + ))} +
+ +
); } - -// ============== useState name and number ==================================================================== - -// export default function ContactForm({ isContactExist, onFormSubmit }) { -// const [name, setName] = useState(''); -// const [number, setNumber] = useState(''); -// -// const handleSubmit = event => { -// event.preventDefault(); -// -// if (name.trim() === '' || number.trim() === '') { -// showError('Make sure all fields are completed!'); -// return; -// } -// -// if (isContactExist(name)) { -// showWarning(`${name} is already in contacts!`); -// return; -// } -// -// onFormSubmit({ name, number }); -// setName(''); -// setNumber(''); -// }; -// -// // const handleNameChange = event => { -// // setName(event.currentTarget.value); -// // }; -// // -// // const handleNumberChange = event => { -// // setNumber(event.currentTarget.value); -// // }; -// -// return ( -//
-// -// -// -// -//
-// ); -// } - -// ============== useState FormData ==================================================================== - -// export default function ContactForm({ isContactExist, onFormSubmit }) { -// const [formData, setFormData] = useState({ name: '', number: '' }); -// -// const handleSubmit = event => { -// event.preventDefault(); -// -// const { name, number } = formData; -// -// if (name.trim() === '' || number.trim() === '') { -// showError('Make sure all fields are completed!'); -// return; -// } -// -// if (isContactExist(name)) { -// showWarning(`${name} is already in contacts!`); -// return; -// } -// -// onFormSubmit({ name, number }); -// setFormData({ name: '', number: '' }); -// }; -// const handleFormChange = event => { -// const { name, value } = event.currentTarget; -// setFormData(prevFormData => ({ ...prevFormData, [name]: value })); -// }; -// return ( -//
-// -// -// -// -//
-// ); -// } diff --git a/src/components/ContactForm/ContactForm.module.css b/src/components/ContactForm/ContactForm.module.css new file mode 100644 index 0000000..bcc58b7 --- /dev/null +++ b/src/components/ContactForm/ContactForm.module.css @@ -0,0 +1,89 @@ +.form { + padding: 40px 20px 20px; + position: relative; + display: grid; + gap: 20px; + justify-content: center; + width: 100%; + max-width: 600px; + color: #2a5d8a; + font-weight: 700; + font-size: 22px; + background-color: white; + border-radius: 16px 16px 0px 16px; + box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px; +} +.formLabel { + position: relative; +} + +.formInput { + padding: 10px 20px; + border: 1px solid #98d8ff; + border-radius: 16px; +} +.formInput::placeholder { + font-weight: 500; + font-size: 18px; + color: #ccc; + font-style: italic; +} + +.form button[type='submit'] { + position: absolute; + right: 0; + top: 100%; + background-color: white; + color: #98d8ff; + border: none; + padding: 16px; + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; + font-size: 24px; + font-weight: 700; + cursor: pointer; + box-shadow: rgba(0, 0, 0, 0.35) 0px 10px 15px; + transition: all 250ms ease-in-out; +} + +.form button[type='submit']:hover { + background-color: #98d8ff; + color: white; +} + +.formRadioWrapper { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + gap: 8px; +} + +.formRadio { + opacity: 0; + visibility: hidden; + position: absolute; + top: 0; + left: 0; + width: 0; + height: 0; +} + +.formRadio:checked + .formRadioColor { + border: 2px solid slategrey; + transform: scale(1.1); +} +.formRadio:hover + .formRadioColor { + transform: scale(1.1); +} + +.formRadioColor { + display: flex; + width: 50px; + height: 50px; + border: 1px solid currentColor; + background-color: currentColor; + border-radius: 16px; + cursor: pointer; + transition: all 200ms linear; +} diff --git a/src/components/ContactList/ContactList.jsx b/src/components/ContactList/ContactList.jsx index a5e20ec..b205730 100644 --- a/src/components/ContactList/ContactList.jsx +++ b/src/components/ContactList/ContactList.jsx @@ -1,30 +1,59 @@ import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { deleteContactAction } from '../../store/contacts/contactsSlice'; import { BiSolidUserRectangle, BiPhone } from 'react-icons/bi'; +import ColorFilter from '../Filter/ColorFilter'; +import Notification from '../Notification'; +import { showInfo } from '../../utils/ToastNotification'; import style from './ContactList.module.css'; -const ContactList = ({ data, onDeleteContact }) => { +const ContactList = () => { + const dispatch = useDispatch(); + const { contacts } = useSelector(state => state.contacts); + const { nameFilter, colorFilter } = useSelector(state => state.filter); + + const filteredContacts = contacts.filter( + contact => + contact.name.toLowerCase().includes(nameFilter.toLowerCase()) && + (colorFilter === '' || contact.color === colorFilter) + ); + + const handleDelete = (id, name) => { + dispatch(deleteContactAction(id)); + showInfo(`Contact ${name} deleted`); + }; return ( - +
+ {contacts.length > 0 && } + {filteredContacts.length > 0 && contacts.length > 0 ? ( + + ) : ( + + )} +
); }; export default ContactList; diff --git a/src/components/ContactList/ContactList.module.css b/src/components/ContactList/ContactList.module.css index a6d9f15..c7771be 100644 --- a/src/components/ContactList/ContactList.module.css +++ b/src/components/ContactList/ContactList.module.css @@ -1,51 +1,72 @@ -.contact__list { - margin: 0 auto; - padding: 40px; +.contactList { + padding: 0 40px; display: grid; - - max-width: 800px; width: 100%; - border: 1px solid #ccc; - border-radius: 4px; + border-radius:16px; + } -.contact__item { +.contactItem { + position: relative; display: grid; gap: 20px; grid-template-columns: 1fr 1fr 0.7fr; align-items: center; - border-radius: 4px; + /*border-radius: 16px;*/ background-color: white; padding: 10px 20px; + box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px; +} +.contactItem:first-child { + border-top-left-radius: 16px; + border-top-right-radius: 16px; +} +.contactItem:first-child .contactDelButton{ + border-top-right-radius: 16px; +} +.contactItem:last-child { + border-bottom-left-radius: 16px; + border-bottom-right-radius: 16px; +} +.contactItem:last-child .contactDelButton{ + border-bottom-right-radius: 16px; } -.contact__item:not(:last-child) { +.contactItem:not(:last-child) { border-bottom: 1px dashed #ccc; } -.contact__item p{ - display: flex; - gap: 6px; - align-items: center; +.nameIcon { + width: 30px; + height: 30px; +} + +.contactItem p { + display: flex; + gap: 6px; + align-items: center; } -.contact__item svg{ +.contactItem svg { color: #2a5d8a; } -.contact__button { - color: white; - font-weight: 700; - font-size: 16px; - background-color: #F85771; - border: 2px solid #F85771; - padding: 8px; - border-radius: 4px; - cursor: pointer; - transition: all 250ms ease-in-out; +.contactDelButton { + padding: 8px 20px; + position: absolute; + right: 0; + top: 0; + height: 100%; + color: #f85771; + font-weight: 700; + font-size: 16px; + border: none; + background-color: transparent; + cursor: pointer; + transition: all 250ms ease-in-out; } -.contact__button:hover { - background-color: white; - color: #F85771; +.contactDelButton:hover { + background-color: #f85771; + color: white; } diff --git a/src/components/Filter/ColorFilter.js b/src/components/Filter/ColorFilter.js new file mode 100644 index 0000000..692771a --- /dev/null +++ b/src/components/Filter/ColorFilter.js @@ -0,0 +1,72 @@ +import { useDispatch, useSelector } from 'react-redux'; +import React, { useState } from 'react'; +import { setColorFilterAction } from '../../store/filter/filterSlice'; +import { DEFAULT_COLOR } from '../../constans/ColorConstans'; + +import style from './Filter.module.css'; + +const ColorFilter = () => { + const dispatch = useDispatch(); + const [activeColor, setActiveColor] = useState(''); + + const { contacts } = useSelector(state => state.contacts); + + const uniqueColors = [...new Set(contacts.map(contact => contact.color))]; + + const isDefaultColorContacts = contacts.some( + contact => contact.color === DEFAULT_COLOR + ); + + const makeActiveButtonClass = color => { + const buttonClass = [style.colorFilterButton]; + activeColor === color && buttonClass.push(style.active); + return buttonClass.join(' '); + }; + + const handleColorFilter = event => { + const selectedColor = event.currentTarget.value; + setActiveColor(selectedColor); + dispatch(setColorFilterAction(selectedColor)); + }; + + return ( +
+ + + {uniqueColors.map( + (color, index) => + color !== DEFAULT_COLOR && ( + + ) + )} + {isDefaultColorContacts && ( + + )} +
+ ); +}; + +export default ColorFilter; diff --git a/src/components/Filter/Filter.jsx b/src/components/Filter/Filter.jsx deleted file mode 100644 index adfc09c..0000000 --- a/src/components/Filter/Filter.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import style from './Filter.module.css'; - -const Filter = ({ value, onChange }) => ( - -); - -export default Filter; diff --git a/src/components/Filter/Filter.module.css b/src/components/Filter/Filter.module.css index f964c1d..e8d6f4b 100644 --- a/src/components/Filter/Filter.module.css +++ b/src/components/Filter/Filter.module.css @@ -1,5 +1,46 @@ -.filter{ - max-width: 800px; - width: 100%; - margin: 0 auto; +.filter { + max-width: calc(100% - 80px); + width: 100%; + margin: 0 auto; + padding: 10px 20px; + border: 1px solid #98d8ff; + border-radius: 16px; + box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px; +} + +.filter::placeholder { + font-weight: 500; + font-size: 18px; + color: #ccc; + font-style: italic; +} + +.colorFilter { + padding-left: 12px; + margin: 0 auto; + display: flex; + align-items: center; + max-width: calc(100% - 110px); + width: 100%; +} + +.colorFilterButton { + margin-left: -10px; + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 40px; + border: none; + cursor: pointer; + border-top-left-radius: 16px; + transition: all 250ms cubic-bezier(0.4, 0, 0.2, 1); +} + +.active { + border-top-right-radius: 16px; + transform: scale(1.1); + box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px; + } diff --git a/src/components/Filter/NameFilter.jsx b/src/components/Filter/NameFilter.jsx new file mode 100644 index 0000000..80e4986 --- /dev/null +++ b/src/components/Filter/NameFilter.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { setNameFilterAction } from '../../store/filter/filterSlice'; +import style from './Filter.module.css'; + +const NameFilter = () => { + const dispatch = useDispatch(); + const { contacts } = useSelector(state => state.contacts); + const { nameFilter } = useSelector(state => state.filter); + + const handleChange = event => { + dispatch(setNameFilterAction(event.target.value)); + }; + + return ( + <> + {contacts.length > 0 && ( + + )} + + ); +}; + +export default NameFilter; diff --git a/src/components/Filter/index.js b/src/components/Filter/index.js index d484ed3..2040eae 100644 --- a/src/components/Filter/index.js +++ b/src/components/Filter/index.js @@ -1 +1 @@ -export { default } from './Filter.jsx'; +export { default } from './NameFilter.jsx'; diff --git a/src/components/Notification/Notification.module.css b/src/components/Notification/Notification.module.css index ab2599d..c8917f1 100644 --- a/src/components/Notification/Notification.module.css +++ b/src/components/Notification/Notification.module.css @@ -1,15 +1,16 @@ -.notification{ - margin: 0 auto; - padding: 40px; - width: 100%; - max-width: 800px; - min-height: 350px; - display: flex; - align-items: center; - justify-content: center; - color: #C6455A; - font-weight: 700; - font-size: 26px; - border: 1px solid #ccc; - border-radius: 4px; +.notification { + margin: 0 auto; + padding: 40px; + width: 100%; + max-width: calc(100% - 80px); + min-height: 350px; + display: flex; + align-items: center; + justify-content: center; + color: #c6455a; + font-weight: 700; + font-size: 26px; + + border-radius: 16px; + box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px; } diff --git a/src/components/Section/Section.module.css b/src/components/Section/Section.module.css index a6f5ed4..b66746c 100644 --- a/src/components/Section/Section.module.css +++ b/src/components/Section/Section.module.css @@ -1,25 +1,11 @@ .section { - padding: 20px 0; + padding: 40px 0; display: grid; gap: 20px; } .section__title { - font-size: 64px; - font-weight: 700; - text-align: center; - text-shadow: - -1px -1px #fff, - -2px -2px #fff, - -1px 1px #fff, - -2px 2px #fff, - 1px 1px #fff, - 2px 2px #fff, - 1px -1px #fff, - 2px -2px #fff, - -3px -3px 2px #bbb, - -3px 3px 2px #bbb, - 3px 3px 2px #bbb, - 3px -3px 2px #bbb; - color: steelblue; + color: #331200; + line-height:40px; + } diff --git a/src/constans/ColorConstans.js b/src/constans/ColorConstans.js new file mode 100644 index 0000000..718efd9 --- /dev/null +++ b/src/constans/ColorConstans.js @@ -0,0 +1,8 @@ +export const colorPickerOptions = [ + { label: 'yellow', color: '#faae20' }, + { label: 'green', color: '#4CAF50' }, + { label: 'blue', color: '#2196F3' }, + { label: 'pink', color: '#E91E63' }, +]; + +export const DEFAULT_COLOR = '#008080'; diff --git a/src/hooks/useLocalStorage.js b/src/hooks/useLocalStorage.js deleted file mode 100644 index 46cd7f7..0000000 --- a/src/hooks/useLocalStorage.js +++ /dev/null @@ -1,14 +0,0 @@ -import { useEffect, useState } from 'react'; - -const useLocalStorage = (key, defaultValue) => { - const [state, setState] = useState( - () => JSON.parse(window.localStorage.getItem(key)) ?? defaultValue, - ); - useEffect(() => { - window.localStorage.setItem(key, JSON.stringify(state)); - }, [key, state]); - - return [state, setState]; -}; - -export default useLocalStorage; diff --git a/src/images/phonebook.png b/src/images/phonebook.png new file mode 100644 index 0000000..2bcf794 Binary files /dev/null and b/src/images/phonebook.png differ diff --git a/src/index.css b/src/index.css index 895c0e0..7680035 100644 --- a/src/index.css +++ b/src/index.css @@ -1,13 +1,11 @@ - - body { margin: 0; + background-color: #fedecc; font-family: -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; - } code { @@ -15,67 +13,43 @@ code { monospace; } -p, h1, h2, ul { +p, +h1, +h2, +ul { margin: 0; - padding: 0; + padding: 0; } -ul{ - list-style: none; +ul { + list-style: none; } .container { + width: 100%; max-width: 1200px; margin: 0 auto; padding: 0 20px; } -.form { - margin: 0 auto; - padding: 20px; - display: grid; - gap: 20px; - justify-content: center; - width: 100%; - max-width: 600px; - color: #2a5d8a; - font-weight: 700; - font-size: 22px; - border: 1px solid #ccc; - border-radius: 4px; -} -.form__label { - display: grid; - grid-template-columns: 0.5fr 1fr; - gap: 10px; - align-items: center; - color: #2a5d8a; - font-weight: 700; - font-size: 22px; -} +/*.active{*/ +/* border: 2px solid red;*/ +/*}*/ -.form__input { - padding: 10px; - border: 1px solid #ccc; - border-radius: 4px; +.wrapper { + display: grid; + gap: 20px; + grid-template-columns: 1fr 0.6fr; + align-items: start; } -.form button{ - background-color: #2a5d8a; - color: white; - border: 2px solid #2a5d8a; - padding: 16px; - border-radius: 4px; - font-size: 24px; - font-weight: 700; - cursor: pointer; - transition: all 250ms ease-in-out; +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 0; } - -.form button:hover{ - background-color: white; - color: #2a5d8a; +.pageTitle { + display: flex; + align-items: center; + gap: 6px; } - - - - diff --git a/src/index.js b/src/index.js index f7996c3..edd3429 100644 --- a/src/index.js +++ b/src/index.js @@ -1,11 +1,18 @@ import React from 'react'; +import { Provider } from 'react-redux'; import ReactDOM from 'react-dom/client'; import App from 'App'; import 'modern-normalize/modern-normalize.css'; import './index.css'; +import { persistor, store } from './store/store'; +import { PersistGate } from 'redux-persist/integration/react'; ReactDOM.createRoot(document.getElementById('root')).render( - + + Loader...} persistor={persistor}> + + + ); diff --git a/src/store/contacts/contactsSlice.js b/src/store/contacts/contactsSlice.js new file mode 100644 index 0000000..bbfdbd1 --- /dev/null +++ b/src/store/contacts/contactsSlice.js @@ -0,0 +1,59 @@ +import { createSlice, nanoid } from '@reduxjs/toolkit'; + +const contactsSlice = createSlice({ + name: 'contacts', + initialState: { + contacts: [ + { + id: 'id-1', + name: 'Rosie Simpson', + number: '459-12-56', + color: '#faae20', + }, + { + id: 'id-2', + name: 'Hermione Kline', + number: '443-89-12', + color: '#4CAF50', + }, + { + id: 'id-3', + name: 'Eden Clements', + number: '645-17-79', + color: '#2196F3', + }, + { + id: 'id-4', + name: 'Annie Copeland', + number: '227-91-26', + color: '#E91E63', + }, + ], + }, + reducers: { + addContactAction: { + prepare: contact => { + return { + payload: { + ...contact, + id: nanoid(), + }, + }; + }, + reducer: (state, action) => { + return { + ...state, + contacts: [action.payload, ...state.contacts], + }; + }, + }, + deleteContactAction: (state, action) => { + state.contacts = state.contacts.filter( + contact => contact.id !== action.payload + ); + }, + }, +}); + +export const { addContactAction, deleteContactAction } = contactsSlice.actions; +export const contactsReducer = contactsSlice.reducer; diff --git a/src/store/filter/filterSlice.js b/src/store/filter/filterSlice.js new file mode 100644 index 0000000..48c0d77 --- /dev/null +++ b/src/store/filter/filterSlice.js @@ -0,0 +1,21 @@ +import { createSlice } from '@reduxjs/toolkit'; + +const filterSlice = createSlice({ + name: 'filter', + initialState: { + nameFilter: '', + colorFilter: '', + }, + reducers: { + setNameFilterAction: (state, action) => { + state.nameFilter = action.payload; + }, + setColorFilterAction: (state, action) => { + state.colorFilter = action.payload; + }, + }, +}); + +export const { setNameFilterAction, setColorFilterAction } = + filterSlice.actions; +export const filterReducer = filterSlice.reducer; diff --git a/src/store/reducer.js b/src/store/reducer.js new file mode 100644 index 0000000..382ba16 --- /dev/null +++ b/src/store/reducer.js @@ -0,0 +1,16 @@ +import storage from 'redux-persist/lib/storage'; +import { persistReducer } from 'redux-persist'; +import { contactsReducer } from './contacts/contactsSlice'; +import { filterReducer } from './filter/filterSlice'; + +const persistConfig = { + key: 'contacts', + storage, + whitelist: ['contacts'], +}; +const persistedReducer = persistReducer(persistConfig, contactsReducer); + +export const reducer = { + contacts: persistedReducer, + filter: filterReducer, +}; diff --git a/src/store/store.js b/src/store/store.js new file mode 100644 index 0000000..7221f4a --- /dev/null +++ b/src/store/store.js @@ -0,0 +1,23 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { reducer } from './reducer'; +import { + persistStore, + FLUSH, + PAUSE, + PERSIST, + PURGE, + REGISTER, + REHYDRATE, +} from 'redux-persist'; + +export const store = configureStore({ + reducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }), +}); + +export const persistor = persistStore(store); diff --git a/src/utils/ToastNotification.js b/src/utils/ToastNotification.js index e7bc770..6e45237 100644 --- a/src/utils/ToastNotification.js +++ b/src/utils/ToastNotification.js @@ -25,3 +25,27 @@ export function showWarning(message) { theme: 'colored', }); } +export function showSuccess(message) { + toast.success(message, { + position: 'top-center', + autoClose: 1000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + theme: 'colored', + }); +} +export function showInfo(message) { + toast.info(message, { + position: 'top-center', + autoClose: 1000, + hideProgressBar: false, + closeOnClick: true, + pauseOnHover: true, + draggable: true, + progress: undefined, + theme: 'colored', + }); +}