Added sort button with asc/desc price sort

This commit is contained in:
lootboxer 2025-07-01 00:16:26 +03:00
parent e5478bb1c6
commit 4127f6af14
13 changed files with 226 additions and 71 deletions

View file

@ -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;

View file

@ -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);
} }

View file

@ -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>

View file

@ -2,7 +2,6 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
margin-bottom: 24px;
} }
.filters { .filters {

View file

@ -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;
}

View file

@ -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;

View file

@ -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 {}

View file

@ -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 >
</>
) )
}; };

View 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;
}

View 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}>&times;</button>
</div>
<div className={styles.modalContent}>
{children}
</div>
</div>
</div>
);
};

View file

@ -1,5 +1,9 @@
.home { .home {
display: flex;
flex-direction: column;
gap: 24px;
height: 100%; height: 100%;
padding: 24px;
} }
.header { .header {

View file

@ -1,59 +1,34 @@
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>
}>По цене</Button>
<Listing filter={filter} isPriceSortAsc={isPriceSortOrderAsc} />
</div > </div >
} }

View file

@ -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
} }
} }