Added sort button with asc/desc price sort
This commit is contained in:
parent
e5478bb1c6
commit
4127f6af14
13 changed files with 226 additions and 71 deletions
|
|
@ -5,6 +5,8 @@
|
||||||
--text-color: #333;
|
--text-color: #333;
|
||||||
--border-color: grey;
|
--border-color: grey;
|
||||||
|
|
||||||
|
--background-color: white;
|
||||||
|
|
||||||
--input-color: #99A3BA;
|
--input-color: #99A3BA;
|
||||||
--input-border: #CDD9ED;
|
--input-border: #CDD9ED;
|
||||||
--input-background: #fff;
|
--input-background: #fff;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,11 @@
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
background-color: var(--background-color);
|
||||||
|
font-color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -41,4 +46,5 @@ body {
|
||||||
h1 {
|
h1 {
|
||||||
font-size: 3.2em;
|
font-size: 3.2em;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
|
font-color: var(--text-color);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import styles from '@/components/home/ExcursionCard.module.scss'
|
import styles from '@/components/home/ExcursionCard.module.scss'
|
||||||
import Button from '@/components/ui/Button/Button';
|
import Button from '@/components/ui/Button/Button';
|
||||||
|
import type { Ref } from 'react'
|
||||||
|
|
||||||
interface ExcursionCardProps {
|
interface ExcursionCardProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
@ -8,14 +9,17 @@ interface ExcursionCardProps {
|
||||||
imageUrl: string;
|
imageUrl: string;
|
||||||
minPeople: number;
|
minPeople: number;
|
||||||
maxPeople: number;
|
maxPeople: number;
|
||||||
|
cost: number;
|
||||||
|
ref: Ref<HTMLDivElement>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ExcursionCard({ title, description, imageUrl, city, minPeople, maxPeople }: ExcursionCardProps) {
|
export default function ExcursionCard({ title, description, imageUrl, city, minPeople, maxPeople, cost, ref }: ExcursionCardProps) {
|
||||||
return <div className={styles.card}>
|
return <div className={styles.card} ref={ref}>
|
||||||
|
|
||||||
<div className={styles.cardImageWrapper}><img src={imageUrl} alt={title} className={styles.cardImage} /></div>
|
<div className={styles.cardImageWrapper}><img src={imageUrl} alt={title} className={styles.cardImage} /></div>
|
||||||
<h3>{title}</h3>
|
<h3>{title}</h3>
|
||||||
<p>город: {city}</p>
|
<p>город: {city}</p>
|
||||||
|
<p>стоимость: {cost}</p>
|
||||||
<p>{description}</p>
|
<p>{description}</p>
|
||||||
<p>Человек: от {minPeople} до {maxPeople}</p>
|
<p>Человек: от {minPeople} до {maxPeople}</p>
|
||||||
<Button onClick={() => console.log(`Booking ${title}`)} >Подробнее</Button>
|
<Button onClick={() => console.log(`Booking ${title}`)} >Подробнее</Button>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters {
|
.filters {
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,8 @@
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(3, 1fr);
|
||||||
justify-content: space-evenly;
|
justify-content: space-evenly;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
background-color: red;
|
||||||
|
height: 30px;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,94 @@
|
||||||
import styles from '@/components/home/Listing.module.scss';
|
import styles from '@/components/home/Listing.module.scss';
|
||||||
import ExcursionCard from '@/components/home/ExcursionCard'
|
import ExcursionCard from '@/components/home/ExcursionCard'
|
||||||
import type { IExcursionCard } from '@/types';
|
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 {
|
interface IListingProps {
|
||||||
excursions: IExcursionCard[]
|
filter?: Partial<IExcursionsFilter>;
|
||||||
|
isPriceSortAsc?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Listing = forwardRef<HTMLDivElement, IListingProps>((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<IExcursionCard[]>([])
|
||||||
|
|
||||||
|
const setCardRef = useCallback((el: HTMLDivElement, index: number) => {
|
||||||
|
cardRefs.current[index] = el;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const apiService = new ApiService()
|
||||||
|
|
||||||
|
|
||||||
|
const fetchExcursions = async (newOffset: number, filter?: Partial<IExcursionsFilter>) => {
|
||||||
|
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 (
|
return (
|
||||||
<div className={styles.excursionsGrid} ref={ref}>
|
<div >
|
||||||
{props.excursions.map((excursion, index) => (
|
<div className={styles.excursionsGrid}>
|
||||||
<ExcursionCard key={index} {...excursion} minPeople={excursion.minCountPeople} maxPeople={excursion.maxCountPeople} />
|
{
|
||||||
))}
|
excursions.map((excursion, index) => (
|
||||||
|
<ExcursionCard
|
||||||
|
{...excursion}
|
||||||
|
key={index}
|
||||||
|
ref={(el) => setCardRef(el, index)}
|
||||||
|
minPeople={excursion.minCountPeople}
|
||||||
|
maxPeople={excursion.maxCountPeople}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</div >
|
||||||
|
<div>{isLoading ? 'loading...' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
}
|
||||||
|
|
||||||
export default Listing;
|
export default Listing;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
.button {
|
||||||
|
display: flex;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background-color: #3498db;
|
background-color: var(--primary-color);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
|
||||||
|
|
@ -14,3 +22,5 @@
|
||||||
background-color: grey;
|
background-color: grey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.iconAfter {}
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,22 @@ import styles from './Button.module.scss';
|
||||||
interface ButtonProps {
|
interface ButtonProps {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
onKeyUp?: KeyboardEventHandler<HTMLButtonElement>
|
onKeyUp?: KeyboardEventHandler<HTMLButtonElement>;
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<button className={`${outline && styles.buttonOutlineContainer} ${styles.sort} ${styles.button}`} onClick={onClick} onKeyUp={onKeyUp}>
|
||||||
<button className={styles.button} onClick={onClick} onKeyUp={onKeyUp}>
|
|
||||||
{children}
|
{children}
|
||||||
|
{
|
||||||
|
iconAfter && <div className={styles.iconAfter}>
|
||||||
|
{iconAfter}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</button >
|
</button >
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
40
src/components/ui/Modal/Modal.module.scss
Normal file
40
src/components/ui/Modal/Modal.module.scss
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
28
src/components/ui/Modal/Modal.tsx
Normal file
28
src/components/ui/Modal/Modal.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={styles.modalOverlay} onClick={onClose}>
|
||||||
|
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
{title && <h2>{title}</h2>}
|
||||||
|
<button className={styles.closeButton} onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalContent}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
.home {
|
.home {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
|
|
|
||||||
|
|
@ -1,60 +1,35 @@
|
||||||
import Listing from '@/components/home/Listing';
|
import Listing from '@/components/home/Listing';
|
||||||
import Filters from '@/components/home/Filters';
|
import Filters from '@/components/home/Filters';
|
||||||
|
import Button from '@/components/ui/Button/Button';
|
||||||
import styles from './Home.module.scss'
|
import styles from './Home.module.scss'
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { IExcursionsFilter } from '@/types';
|
import { IExcursionsFilter } from '@/types';
|
||||||
import ApiService from '@/services/apiService';
|
|
||||||
|
|
||||||
const LIMIT = 3;
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const offset = useRef(0)
|
const [filter, setFilter] = useState({})
|
||||||
const [excursions, setExcursions] = useState([])
|
const [isPriceSortOrderAsc, setIsPriceSortOrderAsc] = useState<boolean>(true)
|
||||||
const [filter, setFilter] = useState<Partial<IExcursionsFilter>>({})
|
function changeFiltersHandle(filter: Partial<IExcursionsFilter>) {
|
||||||
|
|
||||||
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<IExcursionsFilter>) => {
|
|
||||||
|
|
||||||
setExcursions((oldValue) => {
|
|
||||||
|
|
||||||
return [
|
|
||||||
...oldValue,
|
|
||||||
...apiService.getExcursions({ limit: LIMIT, offset: newOffset, filter })
|
|
||||||
]
|
|
||||||
})
|
|
||||||
offset.current += LIMIT
|
|
||||||
}
|
|
||||||
|
|
||||||
const changeFiltersHandle = (filter: Partial<IExcursionsFilter>) => {
|
|
||||||
setFilter(filter)
|
setFilter(filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
observer.observe(listingRef.current)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchExcursions(offset.current)
|
|
||||||
}, [filter])
|
|
||||||
|
|
||||||
return <div className={styles.home}>
|
return <div className={styles.home}>
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
<h1>Экскурсии</h1> {excursions.length}
|
<h1>Экскурсии</h1>
|
||||||
</header>
|
</header>
|
||||||
<Filters onChangeFilter={changeFiltersHandle} />
|
<Filters onChangeFilter={changeFiltersHandle} />
|
||||||
<Listing ref={listingRef} excursions={excursions} />
|
<Button
|
||||||
|
className={styles.sort}
|
||||||
|
onClick={() => (setIsPriceSortOrderAsc((old) => !old))}
|
||||||
|
iconAfter={
|
||||||
|
<div className={styles.sortIcon}
|
||||||
|
style={!isPriceSortOrderAsc ? {
|
||||||
|
transform: 'rotate(180deg)',
|
||||||
|
} : {}}>
|
||||||
|
\/
|
||||||
</div>
|
</div>
|
||||||
|
}>По цене</Button>
|
||||||
|
<Listing filter={filter} isPriceSortAsc={isPriceSortOrderAsc} />
|
||||||
|
</div >
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,15 @@ interface IGetExcursionsRequest {
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
filter?: Partial<IExcursionsFilter>;
|
filter?: Partial<IExcursionsFilter>;
|
||||||
|
isPriceSortAsc?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class ApiService {
|
export default class ApiService {
|
||||||
getExcursions({ limit, offset, filter }: IGetExcursionsRequest): IExcursionCard[] {
|
getExcursions({ limit, offset, filter, isPriceSortAsc }: IGetExcursionsRequest): Promise<IExcursionCard[]> {
|
||||||
console.log(limit, offset, filter);
|
const excursionsSorted = excursions.sort((a, b) => isPriceSortAsc ? a.cost - b.cost : b.cost - a.cost)
|
||||||
|
|
||||||
let result = excursions.slice(offset, offset + limit)
|
let result = excursions.slice(offset, offset + limit)
|
||||||
|
|
||||||
|
|
||||||
if (filter) {
|
if (filter) {
|
||||||
if (filter.city) {
|
if (filter.city) {
|
||||||
result = result.filter((card) => card.city.includes(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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue