interationObserver init on whole listing
This commit is contained in:
parent
935178a66d
commit
e5478bb1c6
31 changed files with 4445 additions and 202 deletions
3673
package-lock.json
generated
Normal file
3673
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||||
|
|
|
||||||
42
src/App.css
42
src/App.css
|
|
@ -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
3
src/App.module.scss
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
.app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
40
src/App.tsx
40
src/App.tsx
|
|
@ -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;
|
||||||
|
|
|
||||||
22
src/assets/styles/_variables.scss
Normal file
22
src/assets/styles/_variables.scss
Normal 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;
|
||||||
|
}
|
||||||
44
src/assets/styles/main.scss
Normal file
44
src/assets/styles/main.scss
Normal 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;
|
||||||
|
}
|
||||||
25
src/components/home/ExcursionCard.module.scss
Normal file
25
src/components/home/ExcursionCard.module.scss
Normal 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;
|
||||||
|
}
|
||||||
23
src/components/home/ExcursionCard.tsx
Normal file
23
src/components/home/ExcursionCard.tsx
Normal 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>
|
||||||
|
};
|
||||||
11
src/components/home/Filters.module.scss
Normal file
11
src/components/home/Filters.module.scss
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
.filtersContainer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
33
src/components/home/Filters.tsx
Normal file
33
src/components/home/Filters.tsx
Normal 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>
|
||||||
|
}
|
||||||
8
src/components/home/Listing.module.scss
Normal file
8
src/components/home/Listing.module.scss
Normal 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;
|
||||||
|
}
|
||||||
21
src/components/home/Listing.tsx
Normal file
21
src/components/home/Listing.tsx
Normal 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;
|
||||||
|
|
||||||
16
src/components/ui/Button/Button.module.scss
Normal file
16
src/components/ui/Button/Button.module.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/components/ui/Button/Button.tsx
Normal file
20
src/components/ui/Button/Button.tsx
Normal 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 >
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
16
src/components/ui/Input/Input.module.scss
Normal file
16
src/components/ui/Input/Input.module.scss
Normal 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%;
|
||||||
|
}
|
||||||
31
src/components/ui/Input/Input.tsx
Normal file
31
src/components/ui/Input/Input.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
81
src/components/ui/NumberRageInput/NumberRageInput.tsx
Normal file
81
src/components/ui/NumberRageInput/NumberRageInput.tsx
Normal 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
229
src/constants/index.ts
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
10
src/main.tsx
10
src/main.tsx
|
|
@ -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>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
7
src/pages/Home.module.scss
Normal file
7
src/pages/Home.module.scss
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
.home {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
60
src/pages/Home.tsx
Normal file
60
src/pages/Home.tsx
Normal 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>
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
38
src/services/apiService.ts
Normal file
38
src/services/apiService.ts
Normal 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
17
src/types/index.d.ts
vendored
Normal 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
0
src/utils/index.ts
Normal 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"]
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
{
|
{
|
||||||
"files": [],
|
"compilerOptions": {
|
||||||
"references": [
|
"baseUrl": ".",
|
||||||
{ "path": "./tsconfig.app.json" },
|
"paths": {
|
||||||
{ "path": "./tsconfig.node.json" }
|
"@/*": ["./src/*"],
|
||||||
]
|
"@server/*": ["./server/src/*"]
|
||||||
|
},
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
|
||||||
}
|
|
||||||
|
|
@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue