Compare commits
No commits in common. "4127f6af14a0c03ddbe6e1bf151fc48c856a98a2" and "935178a66d15a9a8e80c9ffad2871e73c9c04ba2" have entirely different histories.
4127f6af14
...
935178a66d
33 changed files with 202 additions and 4600 deletions
3673
package-lock.json
generated
3673
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -11,14 +11,10 @@
|
||||||
},
|
},
|
||||||
"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
Normal file
42
src/App.css
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
#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;
|
||||||
|
}
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
.app {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
40
src/App.tsx
40
src/App.tsx
|
|
@ -1,17 +1,35 @@
|
||||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
import { useState } from 'react'
|
||||||
import Home from '@/pages/Home';
|
import reactLogo from './assets/react.svg'
|
||||||
import styles from './App.module.scss'
|
import viteLogo from '/vite.svg'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<>
|
||||||
<div className={styles.app}>
|
<div>
|
||||||
<Routes>
|
<a href="https://vite.dev" target="_blank">
|
||||||
<Route path="/" element={<Home />} />
|
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||||
</Routes>
|
</a>
|
||||||
|
<a href="https://react.dev" target="_blank">
|
||||||
|
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
<h1>Vite + React</h1>
|
||||||
);
|
<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
|
||||||
|
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
// Define your CSS variables here
|
|
||||||
:root {
|
|
||||||
--primary-color: #3498db;
|
|
||||||
--secondary-color: #2ecc71;
|
|
||||||
--text-color: #333;
|
|
||||||
--border-color: grey;
|
|
||||||
|
|
||||||
--background-color: white;
|
|
||||||
|
|
||||||
--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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
html {
|
|
||||||
background-color: var(--background-color);
|
|
||||||
font-color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
font-color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
import styles from '@/components/home/ExcursionCard.module.scss'
|
|
||||||
import Button from '@/components/ui/Button/Button';
|
|
||||||
import type { Ref } from 'react'
|
|
||||||
|
|
||||||
interface ExcursionCardProps {
|
|
||||||
title: string;
|
|
||||||
city: string;
|
|
||||||
description: string;
|
|
||||||
imageUrl: string;
|
|
||||||
minPeople: number;
|
|
||||||
maxPeople: number;
|
|
||||||
cost: number;
|
|
||||||
ref: Ref<HTMLDivElement>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ExcursionCard({ title, description, imageUrl, city, minPeople, maxPeople, cost, ref }: ExcursionCardProps) {
|
|
||||||
return <div className={styles.card} ref={ref}>
|
|
||||||
|
|
||||||
<div className={styles.cardImageWrapper}><img src={imageUrl} alt={title} className={styles.cardImage} /></div>
|
|
||||||
<h3>{title}</h3>
|
|
||||||
<p>город: {city}</p>
|
|
||||||
<p>стоимость: {cost}</p>
|
|
||||||
<p>{description}</p>
|
|
||||||
<p>Человек: от {minPeople} до {maxPeople}</p>
|
|
||||||
<Button onClick={() => console.log(`Booking ${title}`)} >Подробнее</Button>
|
|
||||||
</div>
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
.filtersContainer {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters {
|
|
||||||
display: flex;
|
|
||||||
gap: 24px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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>
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
.excursionsGrid {
|
|
||||||
height: fit-content;
|
|
||||||
width: 100%;
|
|
||||||
display: grid;
|
|
||||||
gap: 4rem;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
justify-content: space-evenly;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loader {
|
|
||||||
background-color: red;
|
|
||||||
height: 30px;
|
|
||||||
}
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
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 type { IExcursionsFilter } from '@/types';
|
|
||||||
import ApiService from '@/services/apiService';
|
|
||||||
|
|
||||||
const LIMIT = 3;
|
|
||||||
|
|
||||||
interface IListingProps {
|
|
||||||
filter?: Partial<IExcursionsFilter>;
|
|
||||||
isPriceSortAsc?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div >
|
|
||||||
<div className={styles.excursionsGrid}>
|
|
||||||
{
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Listing;
|
|
||||||
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
.buttonContainer {}
|
|
||||||
|
|
||||||
.buttonOutlineContainer {
|
|
||||||
background-color: var(--background-color);
|
|
||||||
color: var(--primary-color);
|
|
||||||
border-radius: 4px;
|
|
||||||
border: 1px solid var(--primary-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
display: flex;
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background-color: var(--primary-color);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
gap: 8px;
|
|
||||||
width: fit-content;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: grey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.iconAfter {}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { KeyboardEventHandler, type ReactNode } from 'react'
|
|
||||||
|
|
||||||
import styles from './Button.module.scss';
|
|
||||||
|
|
||||||
interface ButtonProps {
|
|
||||||
onClick?: () => void;
|
|
||||||
children?: ReactNode;
|
|
||||||
onKeyUp?: KeyboardEventHandler<HTMLButtonElement>;
|
|
||||||
iconAfter?: ReactNode;
|
|
||||||
outline?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Button({ children, onClick, onKeyUp, iconAfter, outline, className }: ButtonProps) {
|
|
||||||
return (
|
|
||||||
<button className={`${outline && styles.buttonOutlineContainer} ${styles.sort} ${styles.button}`} onClick={onClick} onKeyUp={onKeyUp}>
|
|
||||||
{children}
|
|
||||||
{
|
|
||||||
iconAfter && <div className={styles.iconAfter}>
|
|
||||||
{iconAfter}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</button >
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
.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%;
|
|
||||||
}
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,28 +0,0 @@
|
||||||
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}>×</button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.modalContent}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
@ -1,32 +0,0 @@
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,81 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
@ -1,229 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
68
src/index.css
Normal file
68
src/index.css
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
: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 '@/assets/styles/main.scss'
|
import './index.css'
|
||||||
import App from './App'
|
import App from './App.tsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
// <StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
// </StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
.home {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 24px;
|
|
||||||
height: 100%;
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
import Listing from '@/components/home/Listing';
|
|
||||||
import Filters from '@/components/home/Filters';
|
|
||||||
import Button from '@/components/ui/Button/Button';
|
|
||||||
import styles from './Home.module.scss'
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { IExcursionsFilter } from '@/types';
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [filter, setFilter] = useState({})
|
|
||||||
const [isPriceSortOrderAsc, setIsPriceSortOrderAsc] = useState<boolean>(true)
|
|
||||||
function changeFiltersHandle(filter: Partial<IExcursionsFilter>) {
|
|
||||||
setFilter(filter)
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={styles.home}>
|
|
||||||
<header className={styles.header}>
|
|
||||||
<h1>Экскурсии</h1>
|
|
||||||
</header>
|
|
||||||
<Filters onChangeFilter={changeFiltersHandle} />
|
|
||||||
<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 >
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import type { IExcursionCard, IExcursionsFilter } from '@/types'
|
|
||||||
import { excursions } from "@/constants";
|
|
||||||
|
|
||||||
interface IGetExcursionsRequest {
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
filter?: Partial<IExcursionsFilter>;
|
|
||||||
isPriceSortAsc?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = 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 new Promise((res, reg) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
res(result)
|
|
||||||
}, 2000)
|
|
||||||
})
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
17
src/types/index.d.ts
vendored
17
src/types/index.d.ts
vendored
|
|
@ -1,17 +0,0 @@
|
||||||
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;
|
|
||||||
|
|
||||||
}
|
|
||||||
27
tsconfig.app.json
Normal file
27
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"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,10 +1,7 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"files": [],
|
||||||
"baseUrl": ".",
|
"references": [
|
||||||
"paths": {
|
{ "path": "./tsconfig.app.json" },
|
||||||
"@/*": ["./src/*"],
|
{ "path": "./tsconfig.node.json" }
|
||||||
"@server/*": ["./server/src/*"]
|
]
|
||||||
},
|
|
||||||
"jsx": "react-jsx"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
tsconfig.node.json
Normal file
25
tsconfig.node.json
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"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,13 +1,7 @@
|
||||||
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