diff --git a/package.json b/package.json
index 18768d5..680884b 100644
--- a/package.json
+++ b/package.json
@@ -2,7 +2,7 @@
"name": "react-homework-template",
"version": "0.1.0",
"private": true,
- "homepage": "https://goitacademy.github.io/react-homework-template/",
+ "homepage": "https://exluzive53.github.io/react-redux-AsyncThunk/",
"dependencies": {
"@testing-library/jest-dom": "^5.16.3",
"@testing-library/react": "^12.1.4",
diff --git a/src/App.jsx b/src/App.jsx
new file mode 100644
index 0000000..61bb026
--- /dev/null
+++ b/src/App.jsx
@@ -0,0 +1,60 @@
+import { Suspense, lazy } from 'react';
+import { Route, Routes } from 'react-router-dom';
+
+// import HomePage from 'pages/HomePage';
+// import PostsPage from 'pages/PostsPage';
+// import SearchPage from 'pages/SearchPage';
+// import PostDetailsPage from 'pages/PostDetailsPage';
+
+import { StyledAppContainer, StyledNavLink } from 'App.styled';
+import Loader from 'components/Loader';
+
+const HomePage = lazy(() => import('pages/HomePage'));
+const PostsPage = lazy(() => import('pages/PostsPage'));
+const SearchPage = lazy(() => import('pages/SearchPage'));
+const PostDetailsPage = lazy(() => import('pages/PostDetailsPage'));
+
+/*
+Маршрутизація:
+
+ Google - будь-які посилання на зовнішні ресурси,
+ поза нашим додатком
+
+ Some page
+ Some page - для маршутизації по нашому додатку
+
+ 1. Зміна адресної строки браузера.
+ 2. Підготувати Route для відображення, тієї чи іншої сторінки
+ } />
+
+*/
+
+export const App = () => {
+ return (
+
+
+
+
+
+ }>
+
+ } />
+ } />
+ } />
+ {/* /posts/d12dWAF@ */}
+ } />
+
+
+
+ );
+};
diff --git a/src/App.module.css b/src/App.module.css
new file mode 100644
index 0000000..1aaa40c
--- /dev/null
+++ b/src/App.module.css
@@ -0,0 +1,18 @@
+.header-link {
+ color: black;
+ border: 1px solid black;
+ border-radius: 10px;
+ display: inline-block;
+ padding: 20px;
+ font-size: 22px;
+ text-decoration: none;
+ margin-right: 15px;
+
+ transition: all 0.3s;
+}
+
+.header-link.active {
+ border: 1px solid white;
+ background-color: black;
+ color: white;
+}
diff --git a/src/App.styled.js b/src/App.styled.js
new file mode 100644
index 0000000..8d982a2
--- /dev/null
+++ b/src/App.styled.js
@@ -0,0 +1,66 @@
+import { NavLink } from 'react-router-dom';
+import styled from 'styled-components';
+
+export const StyledAppContainer = styled.div`
+ max-width: 1200px;
+ width: 100%;
+ margin: 0 auto;
+ padding: 0px 15px;
+
+ .title {
+ text-transform: uppercase;
+ text-decoration: overline;
+ color: brown;
+ transition: all 0.3s;
+
+ &:hover,
+ &:focus {
+ background-color: yellow;
+ color: blue;
+ }
+ }
+
+ .postList {
+ padding: 0;
+ list-style-type: none;
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+ }
+
+ .postListItem {
+ padding: 20px;
+ border: 1px solid black;
+ border-radius: 20px;
+ }
+
+ .error {
+ padding: 20px;
+ font-size: 24px;
+ border: 1px solid black;
+ background-color: red;
+ color: white;
+ }
+
+ .header-link {
+ }
+`;
+
+export const StyledNavLink = styled(NavLink)`
+ color: black;
+ border: 1px solid black;
+ border-radius: 10px;
+ display: inline-block;
+ padding: 20px;
+ font-size: 22px;
+ text-decoration: none;
+ margin-right: 15px;
+
+ transition: all 0.3s;
+
+ &.active {
+ border: 1px solid white;
+ background-color: black;
+ color: white;
+ }
+`;
diff --git a/src/assets/images/heart.svg b/src/assets/images/heart.svg
new file mode 100644
index 0000000..bfe45b7
--- /dev/null
+++ b/src/assets/images/heart.svg
@@ -0,0 +1,7 @@
+
+
\ No newline at end of file
diff --git a/src/assets/images/search.svg b/src/assets/images/search.svg
new file mode 100644
index 0000000..a37068a
--- /dev/null
+++ b/src/assets/images/search.svg
@@ -0,0 +1,4 @@
+
\ No newline at end of file
diff --git a/src/components/ErrorMessage.jsx b/src/components/ErrorMessage.jsx
new file mode 100644
index 0000000..895c0c6
--- /dev/null
+++ b/src/components/ErrorMessage.jsx
@@ -0,0 +1,7 @@
+import React from 'react';
+
+const ErrorMessage = ({ message }) => {
+ return
{message}
;
+};
+
+export default ErrorMessage;
diff --git a/src/components/HeaderExapmleModules.jsx b/src/components/HeaderExapmleModules.jsx
new file mode 100644
index 0000000..255a141
--- /dev/null
+++ b/src/components/HeaderExapmleModules.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { NavLink } from 'react-router-dom';
+import css from 'App.module.css';
+
+const HeaderExapmleModules = () => {
+ return (
+
+
+
+ );
+};
+
+export default HeaderExapmleModules;
diff --git a/src/components/Loader.jsx b/src/components/Loader.jsx
new file mode 100644
index 0000000..332fcf4
--- /dev/null
+++ b/src/components/Loader.jsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { ColorRing } from 'react-loader-spinner';
+
+const Loader = () => {
+ return (
+
+
+
+ );
+};
+
+export default Loader;
diff --git a/src/components/PostList.jsx b/src/components/PostList.jsx
new file mode 100644
index 0000000..79ebd97
--- /dev/null
+++ b/src/components/PostList.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import PostListItem from './PostListItem';
+
+const PostList = ({ posts }) => {
+ const showPosts = Array.isArray(posts) && posts.length;
+
+ return (
+
+ {showPosts &&
+ posts.map(post => {
+ return (
+
+ );
+ })}
+
+ );
+};
+
+export default PostList;
diff --git a/src/components/PostListItem.jsx b/src/components/PostListItem.jsx
new file mode 100644
index 0000000..eb4a5dc
--- /dev/null
+++ b/src/components/PostListItem.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+
+const PostListItem = ({ id, title, userId, body }) => {
+ return (
+
+
+ Id: {id}
+ Title: {title}
+ User Id: {userId}
+ Body: {body}
+
+
+ );
+};
+
+export default PostListItem;
diff --git a/src/components/SearchPostForm.jsx b/src/components/SearchPostForm.jsx
new file mode 100644
index 0000000..e1123d1
--- /dev/null
+++ b/src/components/SearchPostForm.jsx
@@ -0,0 +1,18 @@
+import React from 'react';
+
+const SearchPostForm = ({ handleSearchSubmit, fetchAllPosts }) => {
+ return (
+
+ );
+};
+
+export default SearchPostForm;
diff --git a/src/index.css b/src/index.css
index 1aac5f6..a5a7f7e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -13,3 +13,17 @@ code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+}
+
+h1,
+h2,
+h3,
+h4,
+p {
+ margin: 0;
+}
diff --git a/src/index.js b/src/index.js
index 2bde91e..3f42b19 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,10 +1,21 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
-import { App } from 'components/App';
+import { BrowserRouter } from 'react-router-dom';
+import { Provider } from 'react-redux';
+
+import { App } from 'App';
+
+import { persistor, store } from 'redux/store';
+
import './index.css';
+import { PersistGate } from 'redux-persist/integration/react';
ReactDOM.createRoot(document.getElementById('root')).render(
-
-
-
+
+
+
+
+
+
+
);
diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx
new file mode 100644
index 0000000..a9efd61
--- /dev/null
+++ b/src/pages/HomePage.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+
+const HomePage = () => {
+ return (
+
+ HomePage
+
+ );
+}
+
+export default HomePage;
diff --git a/src/pages/PostCommentsPage.jsx b/src/pages/PostCommentsPage.jsx
new file mode 100644
index 0000000..8e0e959
--- /dev/null
+++ b/src/pages/PostCommentsPage.jsx
@@ -0,0 +1,53 @@
+import ErrorMessage from 'components/ErrorMessage';
+import Loader from 'components/Loader';
+import React, { useEffect, useState } from 'react';
+import { useParams } from 'react-router-dom';
+import { findPostCommentsById } from 'services/api';
+
+const PostCommentsPage = () => {
+ const { postId } = useParams();
+ const [comments, setComments] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!postId) return;
+
+ const fetchAllPosts = async () => {
+ try {
+ setIsLoading(true);
+ const commentsData = await findPostCommentsById(postId);
+
+ setComments(commentsData);
+ } catch (error) {
+ setError(error.message);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchAllPosts();
+ }, [postId]);
+
+ return (
+
+ {isLoading &&
}
+ {error &&
}
+ {comments !== null && (
+
+ {comments.map(comment => {
+ return (
+ -
+
Name: {comment.name}
+ Email: {comment.email}
+ Body: {comment.body}
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export default PostCommentsPage;
diff --git a/src/pages/PostDetailsPage.jsx b/src/pages/PostDetailsPage.jsx
new file mode 100644
index 0000000..687aed4
--- /dev/null
+++ b/src/pages/PostDetailsPage.jsx
@@ -0,0 +1,91 @@
+import React, { Suspense, lazy, useEffect, useRef } from 'react';
+import {
+ Link,
+ NavLink,
+ Route,
+ Routes,
+ useLocation,
+ useParams,
+} from 'react-router-dom';
+// import PostCommentsPage from './PostCommentsPage';
+import Loader from 'components/Loader';
+import ErrorMessage from 'components/ErrorMessage';
+
+import { findPostById } from 'services/api';
+import { useDispatch, useSelector } from 'react-redux';
+import {
+ addPost,
+ setError,
+ setIsLoading,
+ setPostDetails,
+} from 'redux/postDetailReducer';
+
+const PostCommentsPage = lazy(() => import('pages/PostCommentsPage'));
+
+const PostDetailsPage = () => {
+ const { postId } = useParams();
+ const location = useLocation();
+ const backLinkHref = useRef(location.state?.from ?? '/');
+
+ const postDetails = useSelector(state => state.postDetails.postDetailsData);
+ const isLoading = useSelector(state => state.postDetails.isLoading);
+ const error = useSelector(state => state.postDetails.error);
+ const dispatch = useDispatch();
+ // const [postDetails, setPostDetails] = useState(null);
+ // const [isLoading, setIsLoading] = useState(false);
+ // const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!postId) return;
+
+ const fetchAllPosts = async () => {
+ try {
+ // setIsLoading(true);
+ dispatch(setIsLoading(true));
+ const postData = await findPostById(postId);
+ // setPostDetails(postData);
+ dispatch(setPostDetails(postData));
+ } catch (error) {
+ // setError(error.message);
+ dispatch(setError(error.message));
+ } finally {
+ // setIsLoading(false);
+ dispatch(setIsLoading(false));
+ }
+ };
+
+ fetchAllPosts();
+ }, [postId, dispatch]);
+
+ return (
+
+
Go Back
+
+
+ {isLoading &&
}
+ {error &&
}
+ {postDetails !== null && (
+
+
Post Title: {postDetails.title}
+
Post Body: {postDetails.body}
+
+ )}
+
+
+
+ Comments
+
+
+
+
}>
+
+ } />
+
+
+
+ );
+};
+
+export default PostDetailsPage;
diff --git a/src/pages/PostsPage.jsx b/src/pages/PostsPage.jsx
new file mode 100644
index 0000000..7ca3634
--- /dev/null
+++ b/src/pages/PostsPage.jsx
@@ -0,0 +1,38 @@
+import ErrorMessage from 'components/ErrorMessage';
+import Loader from 'components/Loader';
+import PostList from 'components/PostList';
+import React, { useEffect, useState } from 'react';
+import { fetchPosts } from 'services/api';
+
+const PostsPage = () => {
+ const [posts, setPosts] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const fetchAllPosts = async () => {
+ try {
+ setIsLoading(true);
+ const postsData = await fetchPosts();
+
+ setPosts(postsData);
+ } catch (error) {
+ setError(error.message);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchAllPosts();
+ }, []);
+
+ return (
+
+ {isLoading &&
}
+ {error &&
}
+
+
+
+ );
+};
+
+export default PostsPage;
diff --git a/src/pages/SearchPage.jsx b/src/pages/SearchPage.jsx
new file mode 100644
index 0000000..b929045
--- /dev/null
+++ b/src/pages/SearchPage.jsx
@@ -0,0 +1,89 @@
+import { ReactComponent as IconSearch } from 'assets/images/search.svg';
+import ErrorMessage from 'components/ErrorMessage';
+import Loader from 'components/Loader';
+import { useEffect, useState } from 'react';
+import { Link, useLocation, useSearchParams } from 'react-router-dom';
+import { findPostById } from 'services/api';
+
+// (async () => { // -- IIFE (Immediately invoked function expression)
+// try {
+// setIsLoading(true);
+// const postData = await findPostById(query);
+
+// setPosts([postData]);
+// } catch (error) {
+// setError(error.message);
+// } finally {
+// setIsLoading(false);
+// }
+// })()
+
+const SearchPage = () => {
+ // /search?query=56
+ const [searchParams, setSearchParams] = useSearchParams();
+ const location = useLocation();
+ const [posts, setPosts] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const query = searchParams.get('query');
+
+ useEffect(() => {
+ if (!query) return;
+
+ const fetchAllPosts = async () => {
+ try {
+ setIsLoading(true);
+ const postData = await findPostById(query);
+
+ setPosts([postData]);
+ } catch (error) {
+ setError(error.message);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchAllPosts();
+ }, [query]);
+
+ const handleFormSubmit = event => {
+ event.preventDefault();
+ const searchValue = event.currentTarget.elements.searchPostId.value;
+
+ setSearchParams({ query: searchValue });
+ };
+
+ return (
+
+
+
+
+ {isLoading && }
+ {error && }
+ {posts !== null &&
+ posts.map(post => (
+
+ Post Id: {post.id}
+ Post Title: {post.title}
+ Post Body: {post.body}
+
+ ))}
+
+
+ );
+};
+
+export default SearchPage;
diff --git a/src/redux/postDetailReducer.js b/src/redux/postDetailReducer.js
new file mode 100644
index 0000000..30b5fec
--- /dev/null
+++ b/src/redux/postDetailReducer.js
@@ -0,0 +1,120 @@
+import { createSlice } from '@reduxjs/toolkit';
+
+// створюємо початковий стан state
+const INITIAL_STATE = {
+ postDetailsData: null,
+ isLoading: false,
+ error: null,
+ posts: [],
+};
+
+const postDetailsSlice = createSlice({
+ // Ім'я слайсу
+ name: 'postDetails',
+ // Початковий стан редюсера слайсу
+ initialState: INITIAL_STATE,
+ // Об'єкт редюсерів
+ reducers: {
+ setIsLoading(state, action) {
+ state.isLoading = action.payload;
+ },
+ setPostDetails(state, action) {
+ state.postDetailsData = action.payload;
+ },
+ setError(state, action) {
+ state.error = action.payload;
+ },
+ addPost(state, action) {
+ // state.posts.push(action.payload);
+ state.posts = [...state.posts, action.payload];
+ },
+ deletePost(state, action) {
+ state.posts = state.posts.filter(post => post.id !== action.payload);
+ // const deletedPostIndex = state.posts.findIndex(post => post.id === action.payload);
+ // state.posts.splice(deletedPostIndex, 1);
+ },
+ },
+});
+
+// Генератори екшенів
+export const { setIsLoading, setPostDetails, setError, addPost, deletePost } =
+ postDetailsSlice.actions;
+// Редюсер слайсу
+export const postDetailsReducer = postDetailsSlice.reducer;
+
+// // Ред'юсер це функція яка приймає state, action
+
+// export const postDetailsReducer = (state = INITIAL_STATE, action) => {
+
+// // action-> {type: 'postDetails/setIsLoading', payload: true}
+// // перевіряємо тип інструкції яка прийшла в наш ред'юсер
+
+// switch (action.type) {
+// case 'postDetails/setIsLoading': {
+// return {
+// ...state,
+
+// // єдиний спосіб коли значення прийдуть це тільки action.payload
+
+// isLoading: action.payload,
+// };
+// }
+
+// case 'postDetails/setPostDetails': {
+// return {
+// ...state,
+// postDetailsData: action.payload,
+// };
+// }
+
+// case 'postDetails/setError': {
+// return {
+// ...state,
+// error: action.payload,
+// };
+// }
+
+// case 'postDetails/addPost': {
+
+// // action.payload - {id: 1, title: '123', body: "hello"}
+// return {
+// ...state,
+// posts: [...state.posts, action.payload],
+// };
+// }
+// case 'postDetails/deletePost': {
+
+// // action.payload -1
+// return {
+// ...state,
+// posts: state.posts.filter(post => post.id !== action.payload),
+// };
+// }
+
+// default:
+// return state;
+// }
+// };
+
+// // Створюємо екшенкріейтори, тобто логіку виносимо в ред'юсер
+
+// export const setIsLoading = payload => {
+// return {
+// type: 'postDetails/setIsLoading',
+// payload,
+// };
+// };
+
+// export const setPostDetails = payload => {
+// return {
+// type: 'postDetails/setPostDetails',
+// payload,
+// };
+// };
+
+// export const setError = payload => {
+// return {
+// type: 'postDetails/setError',
+// payload,
+// };
+// };
diff --git a/src/redux/store.js b/src/redux/store.js
new file mode 100644
index 0000000..a28dcfd
--- /dev/null
+++ b/src/redux/store.js
@@ -0,0 +1,37 @@
+import { configureStore } from '@reduxjs/toolkit';
+import {
+ persistStore,
+ persistReducer,
+ FLUSH,
+ REHYDRATE,
+ PAUSE,
+ PERSIST,
+ PURGE,
+ REGISTER,
+} from 'redux-persist';
+import storage from 'redux-persist/lib/storage';
+import { postDetailsReducer } from './postDetailReducer';
+
+const postDetailsConfig = {
+ key: 'postDetails',
+ storage,
+ // пишемо для відправлення даних в локальне сховище
+ whitelist: ['posts'],
+ // пишемо якщо не хочемо пропускати дані в локльне сховище
+ blacklist: ['filter'],
+};
+
+// configureStore приймає об'єкт опцій
+export const store = configureStore({
+ reducer: {
+ postDetails: persistReducer(postDetailsConfig, postDetailsReducer),
+ },
+ middleware: getDefaultMiddleware =>
+ getDefaultMiddleware({
+ serializableCheck: {
+ ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
+ },
+ }),
+});
+
+export const persistor = persistStore(store);
diff --git a/src/services/api.js b/src/services/api.js
new file mode 100644
index 0000000..46c4d65
--- /dev/null
+++ b/src/services/api.js
@@ -0,0 +1,22 @@
+import axios from 'axios';
+
+export const fetchPosts = async () => {
+ const { data } = await axios.get(
+ 'https://jsonplaceholder.typicode.com/posts/'
+ );
+ return data;
+};
+
+export const findPostById = async postId => {
+ const { data } = await axios.get(
+ `https://jsonplaceholder.typicode.com/posts/${postId}`
+ );
+ return data;
+};
+
+export const findPostCommentsById = async postId => {
+ const { data } = await axios.get(
+ `https://jsonplaceholder.typicode.com/posts/${postId}/comments`
+ );
+ return data;
+};