Добавил модалку
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;
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 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 {
title: string;
@ -14,6 +16,7 @@ interface 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}>
<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.description}>{description}</span>
<span className={styles.people}>Человек: от {minPeople} до {maxPeople}</span>
<Button onClick={() => console.log(`Booking ${title}`)} >Подробнее</Button>
<Button onClick={() => setIsModalVisible((v) => !v)} >Подробнее</Button>
{
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) {
const [filter, setFilter] = useState<Partial<IExcursionsFilter>>({})
const handleRangeChange = (range: { min: number | ''; max: number | '' }) => {
console.log('Выбранный диапазон:', range);
const handleRangeChange = (range: { min: number | 0; max: number | 0 }) => {
setFilter({ ...filter, minCost: +range.min, maxCost: +range.max })
};
return <div className={styles.filtersContainer}
@ -26,7 +26,7 @@ export default function Filters({ onChangeFilter }: IFiltersProps) {
<div className={styles.filters}>
<Input placeholder={'Москва'} label={"Город"} value={filter.city} onChange={(e) => setFilter({ ...filter, city: e.target.value })} />
<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>
</div>
</div>

View file

@ -1,7 +1,7 @@
import styles from '@/components/home/Listing.module.scss';
import ExcursionCard from '@/components/home/ExcursionCard'
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 ApiService from '@/services/apiService';
import Loader from '@/components/ui/Loader/Loader';
@ -13,26 +13,19 @@ interface IListingProps {
isPriceSortAsc?: boolean;
}
const Listing = (props: IListingProps) => {
const Listing = forwardRef((props: IListingProps, ref) => {
const offset = useRef(0)
const cardRefs = useRef([])
const lastCardIndex = useRef(null)
const lastCardRef = 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>) => {
const fetchExcursions = async () => {
setIsLoading(true)
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])
offset.current += LIMIT
offset.current = offset.current + LIMIT
} finally {
setIsLoading(false)
}
@ -40,34 +33,30 @@ const Listing = (props: IListingProps) => {
useEffect(() => {
offset.current = 0
lastCardIndex.current = null
lastCardRef.current = null
setExcursions([])
fetchExcursions(offset.current)
fetchExcursions()
}, [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)
fetchExcursions()
}
})
}, { threshold: .8 })
}, { threshold: .9 })
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);
if (!excursions.length) {
return
}
if (lastCardRef.current !== null) {
observer.unobserve(lastCardRef.current)
}
observer.observe(lastCardRef.current);
return () => observer.disconnect();
}, [excursions])
@ -81,7 +70,7 @@ const Listing = (props: IListingProps) => {
<ExcursionCard
{...excursion}
key={index}
ref={(el) => setCardRef(el, index)}
ref={(el) => { lastCardRef.current = index === excursions.length - 1 ? el : null }}
minPeople={excursion.minCountPeople}
maxPeople={excursion.maxCountPeople}
/>
@ -95,7 +84,7 @@ const Listing = (props: IListingProps) => {
}
</>
);
}
})
export default Listing;

View file

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

View file

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

View file

@ -10,19 +10,24 @@ interface IGetExcursionsRequest {
export default class ApiService {
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.city) {
result = result.filter((card) => card.city.includes(filter.city))
}
if (filter.minCost) {
result = result.filter((card) => card.cost >= +filter.minCost)
result = result.filter((card) => card.cost >= filter.minCost)
}
if (filter.maxCost) {
result = result.filter((card) => card.cost <= +filter.maxCost)
result = result.filter((card) => card.cost <= filter.maxCost)
}
if (filter.countPeople) {
result = result.filter((card) => (
@ -31,6 +36,7 @@ export default class ApiService {
)
}
}
result = result.slice(offset, offset + limit)
return new Promise((res, reg) => {
setTimeout(() => {
res(result)