interationObserver init on whole listing

This commit is contained in:
lootboxer 2025-06-30 01:01:26 +03:00
parent 935178a66d
commit e5478bb1c6
31 changed files with 4445 additions and 202 deletions

3673
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -11,10 +11,14 @@
}, },
"dependencies": { "dependencies": {
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0" "react-dom": "^19.1.0",
"react-imask": "^7.6.1",
"react-router-dom": "^7.6.2",
"sass": "^1.89.2"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.29.0", "@eslint/js": "^9.29.0",
"@types/node": "^24.0.4",
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.2",

View file

@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

3
src/App.module.scss Normal file
View file

@ -0,0 +1,3 @@
.app {
height: 100%;
}

View file

@ -1,35 +1,17 @@
import { useState } from 'react' import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import reactLogo from './assets/react.svg' import Home from '@/pages/Home';
import viteLogo from '/vite.svg' import styles from './App.module.scss'
import './App.css'
function App() { function App() {
const [count, setCount] = useState(0)
return ( return (
<> <Router>
<div> <div className={styles.app}>
<a href="https://vite.dev" target="_blank"> <Routes>
<img src={viteLogo} className="logo" alt="Vite logo" /> <Route path="/" element={<Home />} />
</a> </Routes>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div> </div>
<h1>Vite + React</h1> </Router>
<div className="card"> );
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
} }
export default App export default App;

View file

@ -0,0 +1,22 @@
// Define your CSS variables here
:root {
--primary-color: #3498db;
--secondary-color: #2ecc71;
--text-color: #333;
--border-color: grey;
--input-color: #99A3BA;
--input-border: #CDD9ED;
--input-background: #fff;
--input-placeholder: #CBD1DC;
--input-border-focus: #275EFE;
--group-color: var(--input-color);
--group-border: var(--input-border);
--group-background: #EEF4FF;
--group-color-focus: #fff;
--group-border-focus: var(--input-border-focus);
--group-background-focus: #678EFE;
}

View file

@ -0,0 +1,44 @@
// Import all SCSS partials here
// Add global styles here
@use 'variables.scss';
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
padding: 0;
margin: 0;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
max-width: 1280px;
padding: 2rem;
text-align: center;
font-family: Arial, sans-serif;
display: flex;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}

View file

@ -0,0 +1,25 @@
.card {
display: flex;
flex-direction: column;
height: 100%;
border-radius: 16px;
box-shadow: rgba(0, 0, 0, 0.35) 0px 5px 15px;
padding: 16px;
:last-child {
margin-top: auto;
align-self: center;
width: 100%;
}
}
.cardImageWrapper {
overflow: hidden;
height: 240px;
width: 100%;
}
.cardImage {
height: 100%;
width: auto;
}

View file

@ -0,0 +1,23 @@
import styles from '@/components/home/ExcursionCard.module.scss'
import Button from '@/components/ui/Button/Button';
interface ExcursionCardProps {
title: string;
city: string;
description: string;
imageUrl: string;
minPeople: number;
maxPeople: number;
}
export default function ExcursionCard({ title, description, imageUrl, city, minPeople, maxPeople }: ExcursionCardProps) {
return <div className={styles.card}>
<div className={styles.cardImageWrapper}><img src={imageUrl} alt={title} className={styles.cardImage} /></div>
<h3>{title}</h3>
<p>город: {city}</p>
<p>{description}</p>
<p>Человек: от {minPeople} до {maxPeople}</p>
<Button onClick={() => console.log(`Booking ${title}`)} >Подробнее</Button>
</div>
};

View file

@ -0,0 +1,11 @@
.filtersContainer {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.filters {
display: flex;
gap: 24px;
}

View file

@ -0,0 +1,33 @@
import Input from '@/components/ui/Input/Input'
import NumberRangeInput from '@/components/ui/NumberRageInput/NumberRageInput'
import { useState, KeyboardEvent } from 'react'
import styles from '@/components/home/Filters.module.scss'
import Button from '../ui/Button/Button'
import type { IExcursionsFilter } from '@/types'
interface IFiltersProps {
onChangeFilter: (value: Partial<IExcursionsFilter>) => void
}
export default function Filters({ onChangeFilter }: IFiltersProps) {
const [filter, setFilter] = useState<Partial<IExcursionsFilter>>({})
const handleRangeChange = (range: { min: number | ''; max: number | '' }) => {
console.log('Выбранный диапазон:', range);
};
return <div className={styles.filtersContainer}
onKeyUp={(event) => {
if (event.key === 'Enter') {
onChangeFilter(filter)
}
}}
>
<div className={styles.filters}>
<Input placeholder={'Москва'} label={"Город"} value={filter.city} onChange={(e) => setFilter({ ...filter, city: e.target.value })} />
<NumberRangeInput label='Цена' minLimit={0} maxLimit={99999} onChange={handleRangeChange} />
<Input mask={/^\d{0,2}$/} label={'Кол-во человек'} value={`${filter.countPeople}`} onChange={(e) => +e.target.value} placeholder='10' />
</div>
<Button onClick={() => onChangeFilter(filter)}>Отфильтровать</Button>
</div>
}

View file

@ -0,0 +1,8 @@
.excursionsGrid {
height: fit-content;
width: 100%;
display: grid;
gap: 4rem;
grid-template-columns: repeat(3, 1fr);
justify-content: space-evenly;
}

View file

@ -0,0 +1,21 @@
import styles from '@/components/home/Listing.module.scss';
import ExcursionCard from '@/components/home/ExcursionCard'
import type { IExcursionCard } from '@/types';
import { forwardRef } from 'react';
interface IListingProps {
excursions: IExcursionCard[]
}
const Listing = forwardRef<HTMLDivElement, IListingProps>((props: IListingProps, ref) => {
return (
<div className={styles.excursionsGrid} ref={ref}>
{props.excursions.map((excursion, index) => (
<ExcursionCard key={index} {...excursion} minPeople={excursion.minCountPeople} maxPeople={excursion.maxCountPeople} />
))}
</div>
);
})
export default Listing;

View file

@ -0,0 +1,16 @@
.button {
padding: 0.5rem 1rem;
background-color: #3498db;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 8px;
width: fit-content;
&:hover {
background-color: grey;
}
}

View file

@ -0,0 +1,20 @@
import { KeyboardEventHandler, type ReactNode } from 'react'
import styles from './Button.module.scss';
interface ButtonProps {
onClick?: () => void;
children?: ReactNode;
onKeyUp?: KeyboardEventHandler<HTMLButtonElement>
}
export default function Button({ children, onClick, onKeyUp }: ButtonProps) {
return (
<>
<button className={styles.button} onClick={onClick} onKeyUp={onKeyUp}>
{children}
</button >
</>
)
};

View file

@ -0,0 +1,16 @@
.inputWrapper {
display: flex;
flex-direction: column;
align-items: start;
width: fit-content;
}
.input {
display: flex;
flex-direction: column;
min-height: 24px;
gap: 8px;
border: 2px solid var(--primary-color);
border-radius: 4px;
width: 100%;
}

View file

@ -0,0 +1,31 @@
import styles from './Input.module.scss';
import { IMaskInput } from 'react-imask';
interface InputProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
label?: string;
customClass?: string;
mask?: string | typeof Number | RegExp
}
export default function Input({ value, onChange, placeholder, label, customClass, mask }: InputProps) {
function handleOnChange(e: React.ChangeEvent<HTMLInputElement>) {
}
return (
<div className={`${styles.inputWrapper} ${customClass ?? ''}`}>
{label && <label>{label}</label>}
<IMaskInput
mask={mask}
className={styles.input}
value={value}
onChange={onChange}
placeholder={placeholder}
/>
</div>
);
};

View file

@ -0,0 +1,32 @@
.rangeInput {
display: flex;
flex-direction: column;
}
.container {
display: flex;
gap: 12px;
}
.inputGroup {
display: flex;
gap: 4px;
align-items: center;
}
.label {
font-size: 14px;
margin-bottom: 4px;
}
.input {
min-height: 24px;
border: 2px solid var(--primary-color);
border-radius: 4px;
width: 80px;
}
.error {
color: red;
font-size: 12px;
}

View file

@ -0,0 +1,81 @@
import { useState, ChangeEvent } from 'react';
import styles from '@/components/ui/NumberRageInput/NumberRageInput.module.scss'
import { IMaskInput } from 'react-imask';
interface NumberRange {
min: number | '';
max: number | '';
}
interface NumberRangeInputProps {
minLimit?: number;
maxLimit?: number;
onChange?: (range: NumberRange) => void;
label?: string;
}
function NumberRangeInput({
minLimit = 0,
maxLimit = 1000,
label,
onChange,
}: NumberRangeInputProps) {
{
const [range, setRange] = useState<NumberRange>({ min: '', max: '' });
const [error, setError] = useState<string>('');
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
const newRange: NumberRange = {
...range,
[name]: value,
};
const isInvalidRange =
(newRange.min !== '' && newRange.max !== '' && newRange.min > newRange.max) ||
(newRange.min !== '' && newRange.min < minLimit) ||
(newRange.max !== '' && newRange.max > maxLimit);
if (isInvalidRange) {
setError('Некорректный диапазон');
} else {
setError('');
if (onChange) onChange(newRange);
}
setRange(newRange);
};
return (
<div className={styles.rangeInput}>
{label && <label>{label}</label>}
<div className={styles.container}>
<div className={styles.inputGroup}>
<label className={styles.label}>От:</label>
<IMaskInput
mask={Number}
name="min"
value={range.min}
onChange={handleChange}
placeholder={`${minLimit}`}
className={styles.input}
/>
</div>
<div className={styles.inputGroup}>
<label className={styles.label}>До:</label>
<IMaskInput
mask={Number}
name="max"
value={range.max}
onChange={handleChange}
placeholder={`${maxLimit}`}
className={styles.input}
/>
</div>
</div></div>
);
};
}
export default NumberRangeInput;

229
src/constants/index.ts Normal file
View file

@ -0,0 +1,229 @@
import type { IExcursionCard } from "@/types";
export const excursions: IExcursionCard[] = [
{
"title": "Горное приключение",
"description": "Исследуйте захватывающие горные хребты с нашими гидами. Это идеальный тур для тех, кто ищет уникальные впечатления и незабываемые пейзажи. Вы откроете для себя красоту и величие этого места.",
"imageUrl": "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1470&q=80",
"city": "Аспен",
"cost": 200,
"minCountPeople": 9,
"maxCountPeople": 12
},
{
"title": "Пляжный отдых",
"description": "Расслабьтесь на чистейших пляжах с прозрачной водой. Это идеальный тур для тех, кто ищет уникальные впечатления и незабываемые пейзажи. Вы откроете для себя красоту и величие этого места.",
"imageUrl": "https://images.unsplash.com/photo-1507525428034-b723cf961d3e?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1473&q=80",
"city": "Мальдивы",
"cost": 500,
"minCountPeople": 13,
"maxCountPeople": 16
},
{
"title": "Городской тур",
"description": "Откройте для себя богатую историю и культуру города. Это идеальный тур для тех, кто ищет уникальные впечатления и незабываемые пейзажи. Вы откроете для себя красоту и величие этого места.",
"imageUrl": "https://images.unsplash.com/photo-1485872299829-c673f5194813?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1458&q=80",
"city": "Париж",
"cost": 150,
"minCountPeople": 18,
"maxCountPeople": 23
},
{
"title": "Экспедиция в джунгли",
"description": "Отправляйтесь в дикую природу с нашими опытными проводниками. Это идеальный тур для тех, кто ищет уникальные впечатления и незабываемые пейзажи. Вы откроете для себя красоту и величие этого места.",
"imageUrl": "https://images.unsplash.com/photo-1452421822248-d4c2b47f0c81?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1470&q=80",
"city": "Амазон",
"cost": 300,
"minCountPeople": 5,
"maxCountPeople": 11
},
{
"title": "Пустынное сафари",
"description": "Ощутите азарт катания по дюнам и прогулок на верблюдах. Это идеальный тур для тех, кто ищет уникальные впечатления и незабываемые пейзажи. Вы откроете для себя красоту и величие этого места.",
"imageUrl": "https://images.unsplash.com/photo-1509316785289-025f5b846b35?ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D&auto=format&fit=crop&w=1476&q=80",
"city": "Дубаи",
"cost": 250,
"minCountPeople": 10,
"maxCountPeople": 15
},
{
"title": "Тур по замкам Баварии",
"description": "Погрузитесь в сказочную атмосферу средневековых замков Баварии. Это идеальный тур для ценителей архитектуры, истории и захватывающих пейзажей. Вас ждут незабываемые моменты и отличные фото!",
"imageUrl": "https://images.unsplash.com/photo-1565701302544-25357dec7d83?auto=format&fit=crop&w=1470&q=80",
"city": "Мюнхен",
"cost": 270,
"minCountPeople": 8,
"maxCountPeople": 14
},
{
"title": "Плавание на каяках по фьордам",
"description": "Откройте для себя величие норвежских фьордов, катаясь на каяке в окружении гор и водопадов. Это идеальный способ сбежать от суеты и насладиться природой в полной тишине и спокойствии.",
"imageUrl": "https://images.unsplash.com/photo-1519817650390-64a93db511aa?auto=format&fit=crop&w=1470&q=80",
"city": "Гейрангер",
"cost": 320,
"minCountPeople": 6,
"maxCountPeople": 10
},
{
"title": "Фестиваль фонарей в Таиланде",
"description": "Испытайте магию фестиваля фонарей в Чиангмае, где небо озаряется тысячами огней. Это культурное событие дарит глубокие эмоции, красивые традиции и уникальные фото на память.",
"imageUrl": "https://images.unsplash.com/photo-1573485809116-5541efcb6cbd?auto=format&fit=crop&w=1470&q=80",
"city": "Чиангмай",
"cost": 210,
"minCountPeople": 10,
"maxCountPeople": 20
},
{
"title": "Поездка по винодельням Тосканы",
"description": "Насладитесь ароматами и вкусами лучших тосканских вин на фоне живописных холмов. Вы познакомитесь с виноделами, отдохнете на природе и узнаете много интересного о культуре региона.",
"imageUrl": "https://images.unsplash.com/photo-1587733326949-10608aa03a4a?auto=format&fit=crop&w=1470&q=80",
"city": "Флоренция",
"cost": 290,
"minCountPeople": 8,
"maxCountPeople": 12
},
{
"title": "Полёт на параплане над Альпами",
"description": "Ощутите свободу полёта над заснеженными вершинами Альп! Вас ждёт адреналин, невероятные виды и полное единение с природой. Безопасно, захватывающе и абсолютно незабываемо.",
"imageUrl": "https://images.unsplash.com/photo-1580826889740-906f6f2bfa6f?auto=format&fit=crop&w=1470&q=80",
"city": "Инсбрук",
"cost": 350,
"minCountPeople": 4,
"maxCountPeople": 8
},
{
"title": "Охота за трюфелями",
"description": "Отправьтесь в леса Пьемонта вместе с опытным проводником и обученной собакой, чтобы найти ценные трюфели. В завершение — дегустация блюд с трюфелями и вином.",
"imageUrl": "https://images.unsplash.com/photo-1609851653162-e79e9a4b09b7?auto=format&fit=crop&w=1470&q=80",
"city": "Альба",
"cost": 240,
"minCountPeople": 4,
"maxCountPeople": 8
},
{
"title": "Тур по каналам Венеции",
"description": "Откройте для себя Венецию с воды: гондолы, узкие каналы и старинные мосты. Это романтическое и атмосферное путешествие по одному из самых красивых городов мира.",
"imageUrl": "https://images.unsplash.com/photo-1505765050516-f72dcac9c60b?auto=format&fit=crop&w=1470&q=80",
"city": "Венеция",
"cost": 200,
"minCountPeople": 2,
"maxCountPeople": 6
},
{
"title": "Лаванда Прованса",
"description": "Посетите бескрайние лавандовые поля Прованса в период цветения. Тур включает фотосессию, дегустацию местных продуктов и экскурсию по деревням региона.",
"imageUrl": "https://images.unsplash.com/photo-1508609349937-5ec4ae374ebf?auto=format&fit=crop&w=1470&q=80",
"city": "Валансоль",
"cost": 280,
"minCountPeople": 6,
"maxCountPeople": 12
},
{
"title": "Тропами майя",
"description": "Исследуйте древние руины цивилизации майя в джунглях Юкатана. Вы услышите истории, легенды и увидите пирамиды, сохранившие дух тысячелетий.",
"imageUrl": "https://images.unsplash.com/photo-1580062212579-d8f1080ef22e?auto=format&fit=crop&w=1470&q=80",
"city": "Чичен-Ица",
"cost": 260,
"minCountPeople": 8,
"maxCountPeople": 15
},
{
"title": "Ночь в пустыне",
"description": "Переночуйте под звёздным небом в марокканской пустыне. Вас ждут закат на дюнах, ужин у костра, традиционная музыка и удивительная тишина вокруг.",
"imageUrl": "https://images.unsplash.com/photo-1504384308090-c894fdcc538d?auto=format&fit=crop&w=1470&q=80",
"city": "Мерзуга",
"cost": 310,
"minCountPeople": 6,
"maxCountPeople": 10
},
{
"title": "Сафари на снегоходах",
"description": "Промчитесь по заснеженным просторам Лапландии на снегоходах. Тур подходит для любителей скорости и северной природы. Теплая экипировка включена.",
"imageUrl": "https://images.unsplash.com/photo-1600611447463-6e85a8b394b3?auto=format&fit=crop&w=1470&q=80",
"city": "Рованиеми",
"cost": 330,
"minCountPeople": 4,
"maxCountPeople": 8
},
{
"title": "Тур по музеям Ватикана",
"description": "Откройте величие искусства и истории в музеях Ватикана. Сикстинская капелла, античные залы и коллекции, которыми восхищается весь мир.",
"imageUrl": "https://images.unsplash.com/photo-1549880181-56a44cf4a9a4?auto=format&fit=crop&w=1470&q=80",
"city": "Ватикан",
"cost": 190,
"minCountPeople": 10,
"maxCountPeople": 20
},
{
"title": "Круиз по Нилу",
"description": "Путешествие по Нилу с остановками у древних храмов и памятников. Наслаждайтесь видом пустыни, зелёных берегов и бескрайней реки Египта.",
"imageUrl": "https://images.unsplash.com/photo-1596462502278-c6efc27516d2?auto=format&fit=crop&w=1470&q=80",
"city": "Луксор",
"cost": 370,
"minCountPeople": 10,
"maxCountPeople": 18
},
{
"title": "Поездка по храмам Киото",
"description": "Ощутите дух древней Японии, посетив знаменитые храмы Киото, прогуливаясь по бамбуковым рощам и чайным садам. Это культурное и медитативное путешествие.",
"imageUrl": "https://images.unsplash.com/photo-1526481280690-46998b3087d4?auto=format&fit=crop&w=1470&q=80",
"city": "Киото",
"cost": 300,
"minCountPeople": 6,
"maxCountPeople": 12
},
{
"title": "Ночная фотопрогулка по Токио",
"description": "Прогуляйтесь по сияющим улицам Токио в сопровождении фотографа. Неоновый свет, городская архитектура и жизнь мегаполиса оживают в кадре.",
"imageUrl": "https://images.unsplash.com/photo-1549692520-acc6669e2f0c?auto=format&fit=crop&w=1470&q=80",
"city": "Токио",
"cost": 230,
"minCountPeople": 4,
"maxCountPeople": 10
},
{
"title": "Пеший маршрут по Исландии",
"description": "Пройдите по живописным тропам Исландии — гейзеры, водопады, лавовые поля и ледники. Маршрут для активных путешественников, готовых к открытию нового.",
"imageUrl": "https://images.unsplash.com/photo-1533907650686-70576141c030?auto=format&fit=crop&w=1470&q=80",
"city": "Рейкьявик",
"cost": 350,
"minCountPeople": 8,
"maxCountPeople": 14
},
{
"title": "Тур по Гарни и Гегарду",
"description": "Историческое путешествие по храму Гарни и монастырю Гегард — архитектурным жемчужинам Армении. Вас ждут горные виды и древняя культура.",
"imageUrl": "https://images.unsplash.com/photo-1595252071623-60be9e0348cb?auto=format&fit=crop&w=1470&q=80",
"city": "Ереван",
"cost": 150,
"minCountPeople": 6,
"maxCountPeople": 12
},
{
"title": "Арктическая рыбалка",
"description": "Поймайте треску или лосося в арктических водах. Программа включает обучение, снаряжение и горячий обед у костра после приключения на льду.",
"imageUrl": "https://images.unsplash.com/photo-1504711434969-e33886168f5c?auto=format&fit=crop&w=1470&q=80",
"city": "Норвегия",
"cost": 270,
"minCountPeople": 4,
"maxCountPeople": 8
},
{
"title": "Золотой треугольник Индии",
"description": "Посетите Дели, Агру и Джайпур — три города, отражающих величие индийской истории, архитектуры и культуры. В программе Тадж-Махал, форт Амбер и многое другое.",
"imageUrl": "https://images.unsplash.com/photo-1585241936936-6f19d9b32b0d?auto=format&fit=crop&w=1470&q=80",
"city": "Дели",
"cost": 390,
"minCountPeople": 10,
"maxCountPeople": 18
},
{
"title": "Фото-тур по Сахаре",
"description": "Сделайте потрясающие снимки дюн, караванов и заката в сердце Сахары. Подходит как для начинающих, так и для профессионалов, желающих поймать свет пустыни.",
"imageUrl": "https://images.unsplash.com/photo-1565373798780-e38dc2526e26?auto=format&fit=crop&w=1470&q=80",
"city": "Сахара",
"cost": 310,
"minCountPeople": 5,
"maxCountPeople": 10
}
]

View file

@ -1,68 +0,0 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View file

@ -1,10 +1,10 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import './index.css' import '@/assets/styles/main.scss'
import App from './App.tsx' import App from './App'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> // <StrictMode>
<App /> <App />
</StrictMode>, // </StrictMode>,
) )

View file

@ -0,0 +1,7 @@
.home {
height: 100%;
}
.header {
text-align: center;
}

60
src/pages/Home.tsx Normal file
View file

@ -0,0 +1,60 @@
import Listing from '@/components/home/Listing';
import Filters from '@/components/home/Filters';
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;
export default function Home() {
const offset = useRef(0)
const [excursions, setExcursions] = useState([])
const [filter, setFilter] = useState<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)
}
useEffect(() => {
observer.observe(listingRef.current)
}, [])
useEffect(() => {
fetchExcursions(offset.current)
}, [filter])
return <div className={styles.home}>
<header className={styles.header}>
<h1>Экскурсии</h1> {excursions.length}
</header>
<Filters onChangeFilter={changeFiltersHandle} />
<Listing ref={listingRef} excursions={excursions} />
</div>
}

View file

@ -0,0 +1,38 @@
import type { IExcursionCard, IExcursionsFilter } from '@/types'
import { excursions } from "@/constants";
interface IGetExcursionsRequest {
limit: number;
offset: number;
filter?: Partial<IExcursionsFilter>;
}
export default class ApiService {
getExcursions({ limit, offset, filter }: IGetExcursionsRequest): IExcursionCard[] {
console.log(limit, offset, filter);
let result = excursions.slice(offset, offset + limit)
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)
}
if (filter.maxCost) {
result = result.filter((card) => card.cost <= +filter.maxCost)
}
if (filter.countPeople) {
result = result.filter((card) => (
card.minCountPeople <= filter.countPeople &&
card.maxCountPeople >= filter.countPeople)
)
}
}
return result
}
}

17
src/types/index.d.ts vendored Normal file
View file

@ -0,0 +1,17 @@
export interface IExcursionCard {
description: string;
title: string;
imageUrl: string;
city: string;
minCountPeople: number;
maxCountPeople: number;
cost: number;
}
export interface IExcursionsFilter {
city: string;
countPeople: number;
minCost: number;
maxCost: number;
}

0
src/utils/index.ts Normal file
View file

View file

@ -1,27 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View file

@ -1,7 +1,10 @@
{ {
"files": [], "compilerOptions": {
"references": [ "baseUrl": ".",
{ "path": "./tsconfig.app.json" }, "paths": {
{ "path": "./tsconfig.node.json" } "@/*": ["./src/*"],
] "@server/*": ["./server/src/*"]
},
"jsx": "react-jsx"
}
} }

View file

@ -1,25 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View file

@ -1,7 +1,13 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'path'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
}) })