diff --git a/package-lock.json b/package-lock.json index cfb2b28..5151008 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,6 @@ "history": "5.3.0", "http-status-codes": "2.3.0", "leaflet": "1.7.1", - "nanoid": "5.0.3", "react": "18.2.0", "react-content-loader": "6.0.3", "react-dom": "18.2.0", @@ -44,7 +43,7 @@ "eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-refresh": "0.4.3", "jsdom": "22.1.0", - "typescript": "5.2.2", + "typescript": "5.4.2", "vite": "4.4.11", "vitest": "0.34.6" } @@ -4704,23 +4703,6 @@ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true }, - "node_modules/nanoid": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.3.tgz", - "integrity": "sha512-I7X2b22cxA4LIHXPSqbBCEQSL+1wv8TuoefejsX4HFWyC6jc5JG7CEaxOltiKjc1M+YCS2YkrZZcj4+dytw9GA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.js" - }, - "engines": { - "node": "^18 || >=20" - } - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6161,9 +6143,9 @@ } }, "node_modules/typescript": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz", + "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==", "dev": true, "bin": { "tsc": "bin/tsc", diff --git a/package.json b/package.json index d196163..6bb7b5e 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "history": "5.3.0", "http-status-codes": "2.3.0", "leaflet": "1.7.1", - "nanoid": "5.0.3", "react": "18.2.0", "react-content-loader": "6.0.3", "react-dom": "18.2.0", @@ -46,7 +45,7 @@ "eslint-plugin-react-hooks": "4.6.0", "eslint-plugin-react-refresh": "0.4.3", "jsdom": "22.1.0", - "typescript": "5.2.2", + "typescript": "5.4.2", "vite": "4.4.11", "vitest": "0.34.6" }, diff --git a/src/app.tsx b/src/app.tsx index f2d1a12..bf5bdb7 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,22 +1,13 @@ import Main from './pages/main/main.tsx'; import {BrowserRouter, Navigate, Route, Routes} from 'react-router-dom'; import NotFound from './pages/not-found/not-found.tsx'; -import {AppRoute, AuthStatus, CITIES, DEFAULT_CITY_SLUG} from './const.ts'; +import {AppRoute, CITIES, DEFAULT_CITY_SLUG} from './const.ts'; import Favorites from './pages/favorites/favorites.tsx'; import Offer from './pages/offer/offer.tsx'; -import PrivateRoute from './components/private-route/private-route.tsx'; import Login from './pages/login/login.tsx'; -import PublicRoute from './components/public-route/public-route.tsx'; -import {useActionCreators, useAppSelector} from './hooks/store.ts'; -import {userActions, userSelectors} from './store/slices/user.ts'; -import {useEffect} from 'react'; +import {PrivateRoute, PublicRoute} from './components/access-route/access-route.tsx'; function App() { - const {checkAuth} = useActionCreators(userActions); - const authStatus = useAppSelector(userSelectors.authStatus); - useEffect(() => { - checkAuth(); - }, [authStatus, checkAuth]); return ( @@ -51,7 +42,7 @@ function App() { /> } + element={} /> + function AccessRoute({children}: AccessRouteProps) { + const authStatus = useAppSelector(userSelectors.authStatus); + const location = useLocation(); + + if (authStatus === AuthStatus.Unknown) { + return ; + } + + const redirect = (location.state as Redirect ?? {}).from ?? fallback; + + return ( + authStatus === status + ? children + : + + ); + }; + +const PrivateRoute = createAccessRoute(AuthStatus.Auth, AppRoute.Login); +const PublicRoute = createAccessRoute(AuthStatus.NoAuth, AppRoute.Root); + + +export {PrivateRoute, PublicRoute}; diff --git a/src/components/comments-list/comments-list.tsx b/src/components/comments-list/comments-list.tsx new file mode 100644 index 0000000..89fad6e --- /dev/null +++ b/src/components/comments-list/comments-list.tsx @@ -0,0 +1,39 @@ +import type {OfferFullInfo} from '../../types/offer.ts'; +import {useActionCreators, useAppSelector} from '../../hooks/store.ts'; +import {useEffect} from 'react'; +import {commentsActions, commentsSelectors} from '../../store/slices/comments.ts'; +import Review from '../review/review.tsx'; + +interface CommentsListProps { + offerId: OfferFullInfo['id']; +} + +const MAX_COUNT_COMMENTS = 10; + +function CommentsList ({offerId}: CommentsListProps) { + const {fetchComments} = useActionCreators(commentsActions); + const postCommentStatus = useAppSelector(commentsSelectors.statusPostRequest); + const comments = useAppSelector(commentsSelectors.sortedComments).slice(0, MAX_COUNT_COMMENTS); + const commentsCount = comments.length; + + useEffect(() => { + fetchComments(offerId); + }, [postCommentStatus, offerId]); + + return ( + <> +

+ Reviews · {commentsCount} +

+
    + { + commentsCount > 0 && + comments.map((comment) => + ) + } +
+ + ); +} + +export default CommentsList; diff --git a/src/components/favorite-button/favorite-button.tsx b/src/components/favorite-button/favorite-button.tsx index 410f19c..2d5dfbf 100644 --- a/src/components/favorite-button/favorite-button.tsx +++ b/src/components/favorite-button/favorite-button.tsx @@ -1,12 +1,20 @@ import {classNames} from '../../utils/class-names/class-names.ts'; import {OfferShortInfo} from '../../types/offer.ts'; +import {useActionCreators, useAppSelector} from '../../hooks/store.ts'; +import {favoritesActions, favoritesSelectors} from '../../store/slices/favorites.ts'; +import {useState} from 'react'; +import {AppRoute, AuthStatus, RequestStatus} from '../../const.ts'; +import {toast} from 'react-toastify'; +import {useNavigate} from 'react-router-dom'; +import {userSelectors} from '../../store/slices/user.ts'; interface FavoriteButtonProps { componentType: 'place-card' | 'offer'; isFavorite: OfferShortInfo['isFavorite']; + offerId: OfferShortInfo['id']; } -function FavoriteButton({componentType, isFavorite}: FavoriteButtonProps) { +function FavoriteButton({componentType, isFavorite, offerId}: FavoriteButtonProps) { const sizes = { 'place-card': { width: '18', @@ -18,17 +26,46 @@ function FavoriteButton({componentType, isFavorite}: FavoriteButtonProps) { }, } as const; + const [isFavoriteCurrent, setIsFavoriteCurrent] = useState(isFavorite); + const {toggleFavorite} = useActionCreators(favoritesActions); + const statusToggleFavorite = useAppSelector(favoritesSelectors.statusToggleFavorite); + const authStatus = useAppSelector(userSelectors.authStatus); + const navigate = useNavigate(); + + const isAuth = authStatus === AuthStatus.Auth; + + const onClickHandler = () => { + if (!isAuth) { + navigate(AppRoute.Login); + } + + toast.promise(toggleFavorite({status: Number(!isFavoriteCurrent) as 0 | 1, offerId}).unwrap(), { + pending: 'Sending request', + success: { + render() { + setIsFavoriteCurrent(!isFavoriteCurrent); + return 'Success'; + } + }, + }); + }; + return ( ); diff --git a/src/components/login-form/login-form.tsx b/src/components/form-login/login-form.tsx similarity index 97% rename from src/components/login-form/login-form.tsx rename to src/components/form-login/login-form.tsx index bfc3da5..0317d91 100644 --- a/src/components/login-form/login-form.tsx +++ b/src/components/form-login/login-form.tsx @@ -12,8 +12,6 @@ function LoginForm() { e.preventDefault(); toast.promise(login({email, password}).unwrap(), { pending: 'Loading', - success: 'Success', - error: 'Error', }); }; diff --git a/src/components/form-review/const.ts b/src/components/form-review/const.ts new file mode 100644 index 0000000..13dc888 --- /dev/null +++ b/src/components/form-review/const.ts @@ -0,0 +1,5 @@ +const MIN_LENGTH_COMMENT = 50; +const MAX_LENGTH_COMMENT = 300; +const MIN_STARS_COMMENT = 1; + +export {MAX_LENGTH_COMMENT, MIN_STARS_COMMENT, MIN_LENGTH_COMMENT}; diff --git a/src/components/form-review/form-review.tsx b/src/components/form-review/form-review.tsx index b0145b6..e8b30cc 100644 --- a/src/components/form-review/form-review.tsx +++ b/src/components/form-review/form-review.tsx @@ -1,41 +1,97 @@ -import {MIN_LENGTH_COMMENT, MIN_STARS_COMMENT, RATING_STARS} from '../../const.ts'; -import RatingStar from '../rating-star/rating-star.tsx'; -import type {StarTitle} from '../rating-star/rating-star.tsx'; +import {RATING_STARS} from '../../const.ts'; +import RatingStar, {StarTitle} from '../rating-star/rating-star.tsx'; import {FormEvent, useState} from 'react'; +import {useActionCreators} from '../../hooks/store.ts'; +import {commentsActions} from '../../store/slices/comments.ts'; +import {OfferFullInfo} from '../../types/offer.ts'; +import {MAX_LENGTH_COMMENT, MIN_LENGTH_COMMENT} from './const.ts'; +import {toast} from 'react-toastify'; -function FormReview() { - const [ratingStar, setRatingStar] = useState(0); - const [textAreaValue, setTextAreaValue] = useState(''); +type FormReviewProps = { + offerId: OfferFullInfo['id']; +}; + +type Form = HTMLFormElement & { + rating: RadioNodeList; + review: HTMLTextAreaElement; +}; + +const verifyForm = (comment: string, rating: string) => + comment.length <= MIN_LENGTH_COMMENT || comment.length > MAX_LENGTH_COMMENT || Number(rating) === RATING_STARS.unknown; + +function FormReview({offerId}: FormReviewProps) { + const [isSubmitDisabled, setIsSubmitDisabled] = useState(true); + const [isFormDisabled, setIsFormDisabled] = useState(false); + const {postComment} = useActionCreators(commentsActions); + + const onChangeForm = (evt: FormEvent) => { + const form = evt.currentTarget as Form; + const rating = form.rating.value; + const comment = form.review.value; + setIsSubmitDisabled(verifyForm(comment, rating)); + }; const onFormSubmit = (evt: FormEvent) => { evt.preventDefault(); + const form = evt.currentTarget as Form; + + const clearForm = () => { + form.reset(); + setIsSubmitDisabled(true); + setIsFormDisabled(false); + }; + + const commentToSend = { + offerId, + body: { + comment: form.review.value, + rating: Number(form.rating.value), + }, + }; + setIsFormDisabled(true); + toast.promise(postComment(commentToSend).unwrap(), { + pending: 'Sending comment', + success: { + render() { + clearForm(); + return 'Comment sent'; + } + }, + error: { + render() { + setIsFormDisabled(false); + return 'Failed to send comment'; + } + } + }); }; return (
onFormSubmit(evt)} + onChange={(evt) => onChangeForm(evt)} className="reviews__form form" action="#" method="post" >
- {Object.entries(RATING_STARS).map(([starTitle, starValue]) => + {Object.entries(RATING_STARS).slice(1).map(([starTitle, starValue]) => ( ) )}