diff --git a/src/assets/styles/_variables.scss b/src/assets/styles/_variables.scss index 19fc778..bba170f 100644 --- a/src/assets/styles/_variables.scss +++ b/src/assets/styles/_variables.scss @@ -5,6 +5,8 @@ --text-color: #333; --border-color: grey; + --background-color: white; + --input-color: #99A3BA; --input-border: #CDD9ED; --input-background: #fff; diff --git a/src/assets/styles/main.scss b/src/assets/styles/main.scss index cf4891f..3fcd186 100644 --- a/src/assets/styles/main.scss +++ b/src/assets/styles/main.scss @@ -13,6 +13,11 @@ -moz-osx-font-smoothing: grayscale; } +html { + background-color: var(--background-color); + font-color: var(--text-color); +} + body { padding: 0; margin: 0; @@ -41,4 +46,5 @@ body { h1 { font-size: 3.2em; line-height: 1.1; + font-color: var(--text-color); } diff --git a/src/components/home/ExcursionCard.tsx b/src/components/home/ExcursionCard.tsx index 3fd0a2f..1024b93 100644 --- a/src/components/home/ExcursionCard.tsx +++ b/src/components/home/ExcursionCard.tsx @@ -1,5 +1,6 @@ import styles from '@/components/home/ExcursionCard.module.scss' import Button from '@/components/ui/Button/Button'; +import type { Ref } from 'react' interface ExcursionCardProps { title: string; @@ -8,14 +9,17 @@ interface ExcursionCardProps { imageUrl: string; minPeople: number; maxPeople: number; + cost: number; + ref: Ref; } -export default function ExcursionCard({ title, description, imageUrl, city, minPeople, maxPeople }: ExcursionCardProps) { - return
+export default function ExcursionCard({ title, description, imageUrl, city, minPeople, maxPeople, cost, ref }: ExcursionCardProps) { + return
{title}

{title}

город: {city}

+

стоимость: {cost}

{description}

Человек: от {minPeople} до {maxPeople}

diff --git a/src/components/home/Filters.module.scss b/src/components/home/Filters.module.scss index 57772d4..f15166a 100644 --- a/src/components/home/Filters.module.scss +++ b/src/components/home/Filters.module.scss @@ -2,7 +2,6 @@ display: flex; flex-direction: column; gap: 16px; - margin-bottom: 24px; } .filters { diff --git a/src/components/home/Listing.module.scss b/src/components/home/Listing.module.scss index add648d..cafb4b4 100644 --- a/src/components/home/Listing.module.scss +++ b/src/components/home/Listing.module.scss @@ -6,3 +6,8 @@ grid-template-columns: repeat(3, 1fr); justify-content: space-evenly; } + +.loader { + background-color: red; + height: 30px; +} diff --git a/src/components/home/Listing.tsx b/src/components/home/Listing.tsx index e5c2d98..b667b17 100644 --- a/src/components/home/Listing.tsx +++ b/src/components/home/Listing.tsx @@ -1,21 +1,94 @@ import styles from '@/components/home/Listing.module.scss'; import ExcursionCard from '@/components/home/ExcursionCard' import type { IExcursionCard } from '@/types'; -import { forwardRef } from 'react'; +import { useEffect, useRef, useState, useCallback } from 'react'; +import type { IExcursionsFilter } from '@/types'; +import ApiService from '@/services/apiService'; + +const LIMIT = 3; interface IListingProps { - excursions: IExcursionCard[] + filter?: Partial; + isPriceSortAsc?: boolean; } -const Listing = forwardRef((props: IListingProps, ref) => { +const Listing = (props: IListingProps) => { + const offset = useRef(0) + const cardRefs = useRef([]) + const lastCardIndex = useRef(null) + const [isLoading, setIsLoading] = useState(false) + const [excursions, setExcursions] = useState([]) + + const setCardRef = useCallback((el: HTMLDivElement, index: number) => { + cardRefs.current[index] = el; + }, []); + + const apiService = new ApiService() + + + const fetchExcursions = async (newOffset: number, filter?: Partial) => { + setIsLoading(true) + try { + const newExcursions = await (apiService.getExcursions({ limit: LIMIT, offset: newOffset, filter, isPriceSortAsc: props.isPriceSortAsc })) + setExcursions((prev) => [...prev, ...newExcursions]) + offset.current += LIMIT + } finally { + setIsLoading(false) + } + } + + useEffect(() => { + offset.current = 0 + lastCardIndex.current = null + setExcursions([]) + fetchExcursions(offset.current) + }, [props.filter, props.isPriceSortAsc]) + + useEffect(() => { + if (!excursions.length) { + return + } + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !isLoading) { + fetchExcursions(offset.current, props.filter) + } + }) + }, { threshold: .8 }) + + if (lastCardIndex.current !== null) { + observer.unobserve(cardRefs.current[lastCardIndex.current]); + } + if (cardRefs.current.length) { + lastCardIndex.current = cardRefs.current.length - 1; + const lastEl = cardRefs.current[lastCardIndex.current]; + if (lastEl instanceof Element) { + observer.observe(lastEl); + } + } + + return () => observer.disconnect(); + }, [excursions]) + return ( -
- {props.excursions.map((excursion, index) => ( - - ))} +
+
+ { + excursions.map((excursion, index) => ( + setCardRef(el, index)} + minPeople={excursion.minCountPeople} + maxPeople={excursion.maxCountPeople} + /> + )) + } +
+
{isLoading ? 'loading...' : ''}
); -}) +} export default Listing; diff --git a/src/components/ui/Button/Button.module.scss b/src/components/ui/Button/Button.module.scss index b3dddaf..578bd16 100644 --- a/src/components/ui/Button/Button.module.scss +++ b/src/components/ui/Button/Button.module.scss @@ -1,12 +1,20 @@ +.buttonContainer {} + +.buttonOutlineContainer { + background-color: var(--background-color); + color: var(--primary-color); + border-radius: 4px; + border: 1px solid var(--primary-color); +} + .button { + display: flex; padding: 0.5rem 1rem; - background-color: #3498db; + background-color: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; - display: flex; - flex-direction: column; gap: 8px; width: fit-content; @@ -14,3 +22,5 @@ background-color: grey; } } + +.iconAfter {} diff --git a/src/components/ui/Button/Button.tsx b/src/components/ui/Button/Button.tsx index d156028..3c50a81 100644 --- a/src/components/ui/Button/Button.tsx +++ b/src/components/ui/Button/Button.tsx @@ -5,16 +5,22 @@ import styles from './Button.module.scss'; interface ButtonProps { onClick?: () => void; children?: ReactNode; - onKeyUp?: KeyboardEventHandler + onKeyUp?: KeyboardEventHandler; + iconAfter?: ReactNode; + outline?: boolean; + className?: string; } -export default function Button({ children, onClick, onKeyUp }: ButtonProps) { +export default function Button({ children, onClick, onKeyUp, iconAfter, outline, className }: ButtonProps) { return ( - <> - - + ) }; diff --git a/src/components/ui/Modal/Modal.module.scss b/src/components/ui/Modal/Modal.module.scss new file mode 100644 index 0000000..8f3ba20 --- /dev/null +++ b/src/components/ui/Modal/Modal.module.scss @@ -0,0 +1,40 @@ +.modalOverlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal { + background: white; + padding: 20px; + border-radius: 8px; + max-width: 500px; + width: 100%; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.modalHeader { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.closeButton { + background: none; + border: none; + font-size: 24px; + cursor: pointer; +} + +.modalContent { + margin-bottom: 20px; +} + diff --git a/src/components/ui/Modal/Modal.tsx b/src/components/ui/Modal/Modal.tsx new file mode 100644 index 0000000..18f6030 --- /dev/null +++ b/src/components/ui/Modal/Modal.tsx @@ -0,0 +1,28 @@ +import { ReactNode } from 'react'; +import styles from './Modal.module.scss'; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + children: ReactNode; + title?: string; +} + +export const Modal = ({ isOpen, onClose, children, title }: ModalProps) => { + if (!isOpen) return null; + + return ( +
+
e.stopPropagation()}> +
+ {title &&

{title}

} + +
+
+ {children} +
+
+
+ ); +}; + diff --git a/src/pages/Home.module.scss b/src/pages/Home.module.scss index 1bd1b6f..d7cbab1 100644 --- a/src/pages/Home.module.scss +++ b/src/pages/Home.module.scss @@ -1,5 +1,9 @@ .home { + display: flex; + flex-direction: column; + gap: 24px; height: 100%; + padding: 24px; } .header { diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 5dc265a..e516df4 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -1,60 +1,35 @@ import Listing from '@/components/home/Listing'; import Filters from '@/components/home/Filters'; +import Button from '@/components/ui/Button/Button'; import styles from './Home.module.scss' -import { useEffect, useRef, useState } from 'react'; -import type { IExcursionsFilter } from '@/types'; -import ApiService from '@/services/apiService'; - -const LIMIT = 3; +import { useEffect, useState } from 'react'; +import { IExcursionsFilter } from '@/types'; export default function Home() { - const offset = useRef(0) - const [excursions, setExcursions] = useState([]) - const [filter, setFilter] = useState>({}) - - const apiService = new ApiService() - const listingRef = useRef(null) - - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - fetchExcursions(offset.current) - } - }) - }, { threshold: 0.5 }) - - - const fetchExcursions = (newOffset: number, filter?: Partial) => { - - setExcursions((oldValue) => { - - return [ - ...oldValue, - ...apiService.getExcursions({ limit: LIMIT, offset: newOffset, filter }) - ] - }) - offset.current += LIMIT - } - - const changeFiltersHandle = (filter: Partial) => { + const [filter, setFilter] = useState({}) + const [isPriceSortOrderAsc, setIsPriceSortOrderAsc] = useState(true) + function changeFiltersHandle(filter: Partial) { setFilter(filter) } - useEffect(() => { - observer.observe(listingRef.current) - }, []) - - useEffect(() => { - fetchExcursions(offset.current) - }, [filter]) - return
-

Экскурсии

{excursions.length} +

Экскурсии

- -
+ + +
} diff --git a/src/services/apiService.ts b/src/services/apiService.ts index ffb4eed..e4fecb9 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -5,15 +5,15 @@ interface IGetExcursionsRequest { limit: number; offset: number; filter?: Partial; + isPriceSortAsc?: boolean; } export default class ApiService { - getExcursions({ limit, offset, filter }: IGetExcursionsRequest): IExcursionCard[] { - console.log(limit, offset, filter); + getExcursions({ limit, offset, filter, isPriceSortAsc }: IGetExcursionsRequest): Promise { + const excursionsSorted = excursions.sort((a, b) => isPriceSortAsc ? a.cost - b.cost : b.cost - a.cost) let result = excursions.slice(offset, offset + limit) - if (filter) { if (filter.city) { result = result.filter((card) => card.city.includes(filter.city)) @@ -31,8 +31,11 @@ export default class ApiService { ) } } + return new Promise((res, reg) => { + setTimeout(() => { + res(result) + }, 2000) + }) - - return result } }