Добавил модалку
All checks were successful
/ build (push) Successful in 1m19s
/ deploy (push) Successful in 9s

This commit is contained in:
lootboxer 2025-07-02 17:16:33 +03:00
parent 4ecf83690e
commit c48658b560
7 changed files with 67 additions and 45 deletions

View file

@ -32,3 +32,10 @@
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.modalCardAttributes {
display: flex;
gap: 8px;
justify-content: center;
font-weight: 700;
}

View file

@ -1,6 +1,8 @@
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' import { useState, type Ref } from 'react'
import { createPortal } from 'react-dom';
import { Modal } from '../ui/Modal/Modal';
interface ExcursionCardProps { interface ExcursionCardProps {
title: string; title: string;
@ -14,6 +16,7 @@ interface ExcursionCardProps {
} }
export default function ExcursionCard({ title, description, imageUrl, city, minPeople, maxPeople, cost, ref }: ExcursionCardProps) { export default function ExcursionCard({ title, description, imageUrl, city, minPeople, maxPeople, cost, ref }: ExcursionCardProps) {
const [isModalVisible, setIsModalVisible] = useState(false)
return <div className={styles.card} ref={ref}> 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>
@ -22,6 +25,22 @@ export default function ExcursionCard({ title, description, imageUrl, city, minP
<span className={styles.cost}>стоимость: {cost}</span> <span className={styles.cost}>стоимость: {cost}</span>
<span className={styles.description}>{description}</span> <span className={styles.description}>{description}</span>
<span className={styles.people}>Человек: от {minPeople} до {maxPeople}</span> <span className={styles.people}>Человек: от {minPeople} до {maxPeople}</span>
<Button onClick={() => console.log(`Booking ${title}`)} >Подробнее</Button> <Button onClick={() => setIsModalVisible((v) => !v)} >Подробнее</Button>
</div> {
createPortal(
<Modal isOpen={isModalVisible}
onClose={() => setIsModalVisible(false)}
>
<div className={styles.cardImageWrapper}><img src={imageUrl} alt={title} className={styles.cardImage} /></div>
<h3 className={styles.title}>{title}</h3>
<div className={styles.modalCardAttributes}>
<span className={styles.city}>город: {city}</span>
<span className={styles.cost}>стоимость: {cost}</span>
<span className={styles.people}>Человек: от {minPeople} до {maxPeople}</span>
</div>
<div className={styles.modalCardDescription}>{description}</div>
</Modal>, document.body)
}
</div >
}; };

View file

@ -12,8 +12,8 @@ interface IFiltersProps {
export default function Filters({ onChangeFilter }: IFiltersProps) { export default function Filters({ onChangeFilter }: IFiltersProps) {
const [filter, setFilter] = useState<Partial<IExcursionsFilter>>({}) const [filter, setFilter] = useState<Partial<IExcursionsFilter>>({})
const handleRangeChange = (range: { min: number | ''; max: number | '' }) => { const handleRangeChange = (range: { min: number | 0; max: number | 0 }) => {
console.log('Выбранный диапазон:', range); setFilter({ ...filter, minCost: +range.min, maxCost: +range.max })
}; };
return <div className={styles.filtersContainer} return <div className={styles.filtersContainer}
@ -26,7 +26,7 @@ export default function Filters({ onChangeFilter }: IFiltersProps) {
<div className={styles.filters}> <div className={styles.filters}>
<Input placeholder={'Москва'} label={"Город"} value={filter.city} onChange={(e) => setFilter({ ...filter, city: e.target.value })} /> <Input placeholder={'Москва'} label={"Город"} value={filter.city} onChange={(e) => setFilter({ ...filter, city: e.target.value })} />
<NumberRangeInput label='Цена' minLimit={0} maxLimit={10000} onChange={handleRangeChange} /> <NumberRangeInput label='Цена' minLimit={0} maxLimit={10000} onChange={handleRangeChange} />
<Input mask={/^\d{0,2}$/} label={'Кол-во человек'} value={`${filter.countPeople}`} onChange={(e) => +e.target.value} placeholder='10' /> <Input mask={/^\d{0,2}$/} label={'Кол-во человек'} value={`${filter.countPeople}`} onChange={(e) => setFilter({ ...filter, countPeople: +e.target.value })} placeholder='10' />
<Button onClick={() => onChangeFilter(filter)}>Отфильтровать</Button> <Button onClick={() => onChangeFilter(filter)}>Отфильтровать</Button>
</div> </div>
</div> </div>

View file

@ -1,7 +1,7 @@
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 { useEffect, useRef, useState, useCallback } from 'react'; import { useEffect, useRef, useState, useCallback, type ReactNode, forwardRef, useImperativeHandle } from 'react';
import type { IExcursionsFilter } from '@/types'; import type { IExcursionsFilter } from '@/types';
import ApiService from '@/services/apiService'; import ApiService from '@/services/apiService';
import Loader from '@/components/ui/Loader/Loader'; import Loader from '@/components/ui/Loader/Loader';
@ -13,26 +13,19 @@ interface IListingProps {
isPriceSortAsc?: boolean; isPriceSortAsc?: boolean;
} }
const Listing = (props: IListingProps) => { const Listing = forwardRef((props: IListingProps, ref) => {
const offset = useRef(0) const offset = useRef(0)
const cardRefs = useRef([]) const lastCardRef = useRef(null)
const lastCardIndex = useRef(null)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [excursions, setExcursions] = useState<IExcursionCard[]>([]) const [excursions, setExcursions] = useState<IExcursionCard[]>([])
const setCardRef = useCallback((el: HTMLDivElement, index: number) => {
cardRefs.current[index] = el;
}, []);
const apiService = new ApiService() const apiService = new ApiService()
const fetchExcursions = async () => {
const fetchExcursions = async (newOffset: number, filter?: Partial<IExcursionsFilter>) => {
setIsLoading(true) setIsLoading(true)
try { try {
const newExcursions = await (apiService.getExcursions({ limit: LIMIT, offset: newOffset, filter, isPriceSortAsc: props.isPriceSortAsc })) const newExcursions = await (apiService.getExcursions({ limit: LIMIT, offset: offset.current, filter: props.filter, isPriceSortAsc: props.isPriceSortAsc }))
setExcursions((prev) => [...prev, ...newExcursions]) setExcursions((prev) => [...prev, ...newExcursions])
offset.current += LIMIT offset.current = offset.current + LIMIT
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
@ -40,34 +33,30 @@ const Listing = (props: IListingProps) => {
useEffect(() => { useEffect(() => {
offset.current = 0 offset.current = 0
lastCardIndex.current = null lastCardRef.current = null
setExcursions([]) setExcursions([])
fetchExcursions(offset.current) fetchExcursions()
}, [props.filter, props.isPriceSortAsc]) }, [props.filter, props.isPriceSortAsc])
useEffect(() => { useEffect(() => {
if (!excursions.length) {
return
}
const observer = new IntersectionObserver((entries) => { const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (entry.isIntersecting && !isLoading) { if (entry.isIntersecting && !isLoading) {
fetchExcursions(offset.current, props.filter) fetchExcursions()
} }
}) })
}, { threshold: .8 }) }, { threshold: .9 })
if (lastCardIndex.current !== null) { if (!excursions.length) {
observer.unobserve(cardRefs.current[lastCardIndex.current]); return
} }
if (cardRefs.current.length) {
lastCardIndex.current = cardRefs.current.length - 1; if (lastCardRef.current !== null) {
const lastEl = cardRefs.current[lastCardIndex.current]; observer.unobserve(lastCardRef.current)
if (lastEl instanceof Element) {
observer.observe(lastEl);
}
} }
observer.observe(lastCardRef.current);
return () => observer.disconnect(); return () => observer.disconnect();
}, [excursions]) }, [excursions])
@ -81,7 +70,7 @@ const Listing = (props: IListingProps) => {
<ExcursionCard <ExcursionCard
{...excursion} {...excursion}
key={index} key={index}
ref={(el) => setCardRef(el, index)} ref={(el) => { lastCardRef.current = index === excursions.length - 1 ? el : null }}
minPeople={excursion.minCountPeople} minPeople={excursion.minCountPeople}
maxPeople={excursion.maxCountPeople} maxPeople={excursion.maxCountPeople}
/> />
@ -95,7 +84,7 @@ const Listing = (props: IListingProps) => {
} }
</> </>
); );
} })
export default Listing; export default Listing;

View file

@ -15,9 +15,10 @@
background: white; background: white;
padding: 20px; padding: 20px;
border-radius: 8px; border-radius: 8px;
max-width: 500px; max-width: 60%;
width: 100%; height: 100%;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: scroll;
} }
.modalHeader { .modalHeader {
@ -35,6 +36,6 @@
} }
.modalContent { .modalContent {
height: 100%;
margin-bottom: 20px; margin-bottom: 20px;
} }

View file

@ -9,7 +9,7 @@ export default function Home() {
const [filter, setFilter] = useState({}) const [filter, setFilter] = useState({})
const [isPriceSortOrderAsc, setIsPriceSortOrderAsc] = useState<boolean>(true) const [isPriceSortOrderAsc, setIsPriceSortOrderAsc] = useState<boolean>(true)
function changeFiltersHandle(filter: Partial<IExcursionsFilter>) { function changeFiltersHandle(filter: Partial<IExcursionsFilter>) {
setFilter(filter) setFilter({ ...filter }) // Использую деструктуризацию для вызова перерисовки при нажатии на энтер
} }
return <div className={styles.home}> return <div className={styles.home}>
@ -21,7 +21,7 @@ export default function Home() {
<div className={styles.sort}> <div className={styles.sort}>
Сортировка: Сортировка:
<Button <Button
onClick={() => (setIsPriceSortOrderAsc((old) => !old))} onClick={() => (setIsPriceSortOrderAsc((i) => !i))}
iconAfter={ iconAfter={
<div className={styles.sortIcon} <div className={styles.sortIcon}
style={!isPriceSortOrderAsc ? { style={!isPriceSortOrderAsc ? {

View file

@ -10,19 +10,24 @@ interface IGetExcursionsRequest {
export default class ApiService { export default class ApiService {
getExcursions({ limit, offset, filter, isPriceSortAsc }: IGetExcursionsRequest): Promise<IExcursionCard[]> { getExcursions({ limit, offset, filter, isPriceSortAsc }: IGetExcursionsRequest): Promise<IExcursionCard[]> {
const excursionsSorted = excursions.sort((a, b) => isPriceSortAsc ? a.cost - b.cost : b.cost - a.cost) let result = JSON.parse(JSON.stringify(excursions))
let result = excursions.slice(offset, offset + limit)
if (isPriceSortAsc) {
result = result.sort((a, b) => a.cost - b.cost)
} else {
result = result.sort((a, b) => b.cost - a.cost)
}
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))
} }
if (filter.minCost) { if (filter.minCost) {
result = result.filter((card) => card.cost >= +filter.minCost) result = result.filter((card) => card.cost >= filter.minCost)
} }
if (filter.maxCost) { if (filter.maxCost) {
result = result.filter((card) => card.cost <= +filter.maxCost) result = result.filter((card) => card.cost <= filter.maxCost)
} }
if (filter.countPeople) { if (filter.countPeople) {
result = result.filter((card) => ( result = result.filter((card) => (
@ -31,6 +36,7 @@ export default class ApiService {
) )
} }
} }
result = result.slice(offset, offset + limit)
return new Promise((res, reg) => { return new Promise((res, reg) => {
setTimeout(() => { setTimeout(() => {
res(result) res(result)