Add docker

This commit is contained in:
Evgenii Saenko
2025-12-17 11:51:25 +03:00
parent a01b46182e
commit 4f6700e0e2
110 changed files with 8838 additions and 181 deletions

View File

@@ -0,0 +1,58 @@
import { Grid, Card, CardContent, Typography, Box, Container } from '@mui/material';
import SecurityIcon from '@mui/icons-material/Security';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import SearchIcon from '@mui/icons-material/Search';
import UpdateIcon from '@mui/icons-material/Update';
const features = [
{
icon: <SecurityIcon fontSize="large" color="primary" />,
title: 'Прозрачность и надёжность',
text: 'Все представленные данные основаны на официальных источниках и актуальных предложениях банков.',
},
{
icon: <AutoAwesomeIcon fontSize="large" color="primary" />,
title: 'Современные технологии',
text: 'Сайт создан с использованием современных веб-технологий для стабильной и быстрой работы.',
},
{
icon: <SearchIcon fontSize="large" color="primary" />,
title: 'Удобный поиск услуг',
text: 'Интерактивный каталог позволяет быстро находить нужные продукты по типу, ставке или сроку.',
},
{
icon: <UpdateIcon fontSize="large" color="primary" />,
title: 'Актуальная информация',
text: 'Информация регулярно обновляется, чтобы пользователи всегда получали свежие данные и выгодные предложения.',
},
];
export const Features = () => {
return (
<Container maxWidth="lg">
<Box sx={{ py: 8 }}>
<Typography component="h2" variant="h2" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Наши преимущества
</Typography>
<Grid container spacing={4} sx={{ pt: 5 }}>
{features.map((item, i) => (
<Grid key={i} sx={{ display: 'flex' }} size={6}>
<Card sx={{ height: '100%', textAlign: 'center', p: 2 }}>
<Box sx={{ mt: 2 }}>{item.icon}</Box>
<CardContent>
<Typography variant="h6" gutterBottom>
{item.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{item.text}
</Typography>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</Box>
</Container>
);
};

View File

@@ -0,0 +1,18 @@
import { Container, Box, Typography } from '@mui/material';
import { FeedbackForm } from './form.tsx';
export const Feedback = () => {
return (
<Container id="contacts">
<Box sx={{ pt: 2, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Typography component="h2" variant="h2" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Свяжитесь с нами
</Typography>
<Typography component="h5" variant="h5" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Мы поможем разобраться в услугах и ответим на все вопросы.
</Typography>
<FeedbackForm />
</Box>
</Container>
);
};

View File

@@ -0,0 +1,156 @@
import { type ChangeEvent, type FormEvent, useId, useState } from 'react';
import Alert from '@mui/material/Alert';
import { Button, Stack, TextField } from '@mui/material';
import { leadsApi, type LeadCreatePayload } from '../../../api/index.ts';
import { ApiError } from '../../../api/httpClient.ts';
type FeedbackFormFields = {
fullName: string;
email: string;
phone: string;
};
type FeedbackFormErrors = Record<keyof FeedbackFormFields, string | null>;
const defaultFormState: FeedbackFormFields = {
fullName: '',
email: '',
phone: '',
};
const createDefaultErrors = (): FeedbackFormErrors => ({
fullName: null,
email: null,
phone: null,
});
export const FeedbackForm = () => {
const formInstanceId = useId();
const [form, setForm] = useState<FeedbackFormFields>(defaultFormState);
const [errors, setErrors] = useState<FeedbackFormErrors>(createDefaultErrors());
const [alert, setAlert] = useState<{ type: 'success' | 'error'; message: string } | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const { name, value } = event.target;
setForm((prev) => ({
...prev,
[name]: value,
}));
setErrors((prev) => ({
...prev,
[name]: null,
}));
};
const validate = () => {
const nextErrors = createDefaultErrors();
let isValid = true;
if (!form.fullName.trim()) {
nextErrors.fullName = 'Укажите ваше имя';
isValid = false;
}
if (!form.email.trim()) {
nextErrors.email = 'Введите email';
isValid = false;
} else if (!/^[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}$/.test(form.email.trim())) {
nextErrors.email = 'Введите корректный email';
isValid = false;
}
if (form.phone.trim() && form.phone.trim().length < 5) {
nextErrors.phone = 'Введите телефон полностью';
isValid = false;
}
setErrors(nextErrors);
return isValid;
};
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setAlert(null);
if (!validate()) {
setAlert({ type: 'error', message: 'Пожалуйста, проверьте правильность заполнения формы.' });
return;
}
setIsSubmitting(true);
const normalizedPhone = form.phone.trim();
const payload: LeadCreatePayload = {
fullName: form.fullName.trim(),
email: form.email.trim(),
...(normalizedPhone ? { phone: normalizedPhone } : {}),
};
try {
await leadsApi.create(payload);
setAlert({ type: 'success', message: 'Спасибо! Мы свяжемся с вами в ближайшее время.' });
setForm(defaultFormState);
setErrors(createDefaultErrors());
} catch (err) {
const message =
err instanceof ApiError ? err.message || 'Не удалось отправить заявку. Попробуйте ещё раз.' : 'Не удалось отправить заявку. Попробуйте ещё раз.';
setAlert({ type: 'error', message });
} finally {
setIsSubmitting(false);
}
};
return (
<Stack component="form" onSubmit={handleSubmit} noValidate sx={{ alignItems: 'center', width: { xs: '100%', sm: '70%' } }} spacing={1.5}>
{alert && <Alert severity={alert.type}>{alert.message}</Alert>}
<TextField
id={`${formInstanceId}-fullName`}
name="fullName"
size="small"
variant="outlined"
placeholder="Иванов Иван"
value={form.fullName}
onChange={handleChange}
error={Boolean(errors.fullName)}
helperText={errors.fullName}
fullWidth
autoComplete="name"
/>
<Stack useFlexGap sx={{ alignItems: 'center', width: { xs: '100%' }, flexDirection: 'row' }} spacing={1}>
<TextField
id={`${formInstanceId}-email`}
name="email"
size="small"
variant="outlined"
placeholder="email@example.com"
value={form.email}
onChange={handleChange}
error={Boolean(errors.email)}
helperText={errors.email}
fullWidth
autoComplete="email"
/>
<TextField
id={`${formInstanceId}-phone`}
name="phone"
size="small"
variant="outlined"
placeholder="+7 999 123 45 67"
value={form.phone}
onChange={handleChange}
error={Boolean(errors.phone)}
helperText={errors.phone}
fullWidth
autoComplete="tel"
inputProps={{ inputMode: 'tel' }}
/>
</Stack>
<Button type="submit" variant="outlined" color="info" size="small" disabled={isSubmitting}>
{isSubmitting ? 'Отправляем...' : 'Оставить заявку'}
</Button>
</Stack>
);
};

View File

@@ -0,0 +1,52 @@
import { Typography, Stack, Box, Container } from '@mui/material';
import { FeedbackForm } from './form.tsx';
export const Hero = () => {
return (
<Box
id="hero"
sx={(theme) => ({
width: '100%',
backgroundRepeat: 'no-repeat',
backgroundImage: 'radial-gradient(ellipse 80% 50% at 50% -20%, hsl(210, 100%, 90%), transparent)',
...theme.applyStyles('dark', {
backgroundImage: 'radial-gradient(ellipse 80% 50% at 50% -20%, hsl(210, 100%, 16%), transparent)',
}),
})}
>
<Container
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pt: { xs: 14, sm: 20 },
pb: { xs: 8, sm: 12 },
}}
>
<Stack spacing={2} useFlexGap sx={{ alignItems: 'center', width: { xs: '100%', sm: '70%' } }}>
<Typography
variant="h1"
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
alignItems: 'center',
fontSize: 'clamp(3rem, 10vw, 3.5rem)',
}}
>
Информационный портал о банковских услугах
</Typography>
<Typography
sx={{
textAlign: 'center',
color: 'text.secondary',
width: { sm: '100%', md: '80%' },
}}
>
Объединяем инновации и надёжность, чтобы финансовые решения стали простыми и доступными каждому.
</Typography>
<FeedbackForm />
</Stack>
</Container>
</Box>
);
};

View File

@@ -0,0 +1,102 @@
import { Container, Box, Typography, Grid, Card, CardContent, Stack } from '@mui/material';
import Logo1 from '../../../assets/partners/logo1.png';
import Logo2 from '../../../assets/partners/logo2.png';
import Logo3 from '../../../assets/partners/logo3.png';
import Logo4 from '../../../assets/partners/logo4.png';
import Logo5 from '../../../assets/partners/logo5.png';
import Logo6 from '../../../assets/partners/logo6.png';
import Logo7 from '../../../assets/partners/logo7.png';
import Logo8 from '../../../assets/partners/logo8.png';
import Logo9 from '../../../assets/partners/logo9.png';
import Logo10 from '../../../assets/partners/logo10.png';
export const Partners = () => {
return (
<Container>
<Box>
<Typography component="h2" variant="h2" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Наши Партнеры
</Typography>
<Stack direction="column" spacing={2} sx={{ width: '100%' }}>
<Grid container spacing={2}>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo1} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo2} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo3} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo4} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo5} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={4}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo6} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
</Grid>
<Grid container spacing={2}>
<Grid size={3}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo7} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={3}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo8} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={3}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo9} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
<Grid size={3}>
<Card sx={{ textAlign: 'center', p: 2 }}>
<CardContent>
<img src={Logo10} style={{ width: '100%' }} />
</CardContent>
</Card>
</Grid>
</Grid>
</Stack>
</Box>
</Container>
);
};

View File

@@ -0,0 +1,90 @@
import React, { useEffect } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import CardHeader from '@mui/material/CardHeader';
import Container from '@mui/material/Container';
import Grid from '@mui/material/Grid';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { observer } from 'mobx-react-lite';
import { useStore } from '../../../shared/hooks/useStore.ts';
import { formatDate, getDateValue } from '../../news/utils/formatDate.ts';
const RecentNewsComponent: React.FC = () => {
const { news } = useStore();
useEffect(() => {
const load = async () => {
try {
await news.fetch({ limit: 10 });
} catch {
/* errors surfaced via store state */
}
};
void load();
}, [news]);
const visibleNews = news.list
.slice()
.sort((a, b) => getDateValue(b.publishedAt) - getDateValue(a.publishedAt))
.slice(0, 3);
const hasNews = visibleNews.length > 0;
return (
<Container maxWidth="lg">
<Box sx={{ display: { xs: 'none', md: 'flex', width: '100%' }, mt: '2rem' }}>
<Stack direction="column" spacing={2} sx={{ width: '100%' }}>
<Typography component="h2" variant="h2" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Последние новости
</Typography>
{news.isLoading && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Загружаем последние новости...
</Typography>
)}
{news.error && (
<Typography sx={{ textAlign: 'center' }} color="error">
{news.error}
</Typography>
)}
{hasNews && (
<Grid container spacing={2}>
{visibleNews.map((item) => (
<Grid key={item.id} size={{ xs: 12, md: 6, lg: 4 }}>
<Card
key={item.id}
variant="outlined"
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
flexGrow: 1,
height: '100%',
}}
>
<CardHeader title={item.title} subheader={formatDate(item.publishedAt)} />
<CardContent>
<Typography gutterBottom>{item.summary}</Typography>
<Link href={item.href}>Читать полностью</Link>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
<Link href="/news" sx={{ alignSelf: 'center' }}>
Лента новостей
</Link>
</Stack>
</Box>
</Container>
);
};
export const RecentNews = observer(RecentNewsComponent);

View File

@@ -0,0 +1,98 @@
import React, { useEffect } from 'react';
import Box from '@mui/material/Box';
import Container from '@mui/material/Container';
import Typography from '@mui/material/Typography';
import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import CardContent from '@mui/material/CardContent';
import Link from '@mui/material/Link';
import Stack from '@mui/material/Stack';
import { observer } from 'mobx-react-lite';
import { useStore } from '../../../shared/hooks/useStore.ts';
import { formatPrice } from '../../services/utils/formatPrice.ts';
const ServicesComponent: React.FC = () => {
const { services } = useStore();
useEffect(() => {
const load = async () => {
try {
await services.fetch({ limit: 10 });
} catch {
/* handled via store state */
}
};
void load();
}, [services]);
const visibleServices = services.list
.slice()
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.slice(0, 3);
const hasServices = visibleServices.length > 0;
return (
<Container maxWidth="lg">
<Box sx={{ display: 'flex', width: '100%' }}>
<Stack direction="column" spacing={2} sx={{ width: '100%' }}>
<Typography id="services_anchor" component="h2" variant="h2" gutterBottom sx={{ color: 'text.primary', textAlign: 'center' }}>
Наши услуги
</Typography>
{services.isLoading && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Загружаем услуги...
</Typography>
)}
{services.error && (
<Typography sx={{ textAlign: 'center' }} color="error">
{services.error}
</Typography>
)}
{hasServices && (
<Grid container spacing={2}>
{visibleServices.map((service) => (
<Grid key={service.id} size={{ xs: 12, md: 6, lg: 4 }}>
<Card
variant="outlined"
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
}}
>
<CardHeader title={service.title} subheader={service.category?.name ?? 'Без категории'} sx={{ pb: 0 }} />
<CardContent>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
{formatPrice(service.priceFrom)}
</Typography>
<Typography gutterBottom color="text.secondary">
{service.description}
</Typography>
<Link href={service.href}>Подробнее</Link>
</CardContent>
</Card>
</Grid>
))}
</Grid>
)}
{!services.isLoading && !services.error && !hasServices && (
<Typography sx={{ textAlign: 'center' }} color="text.secondary">
Услуги скоро появятся.
</Typography>
)}
<Link href={'/services'} sx={{ alignSelf: 'center' }}>
Все услуги
</Link>
</Stack>
</Box>
</Container>
);
};
export const Services = observer(ServicesComponent);