final flow

This commit is contained in:
Zakhar
2026-08-30 14:47:34 +03:00
parent 9dc251bd8f
commit b016abdba2
27 changed files with 1268 additions and 202 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ const ArrowWrap = styled.div<{ $open: boolean }>`
`;
const Content = styled.div<{ $open: boolean }>`
max-height: ${({ $open }) => ($open ? '500px' : '0')};
max-height: ${({ $open }) => ($open ? 'auto' : '0')};
overflow: hidden;
transition: max-height 0.4s ease, padding 0.3s ease;
`;
+42 -2
View File
@@ -8,6 +8,7 @@ import type { Product } from '../../types/cart';
import { CartItem } from './CartItem';
import { CartFooter } from './CartFooter';
import { ProductModal } from './ProductModal';
import {useGlobalZustandState} from "../../hooks/useGlobalZustandState";
interface CartProps {
products: Product[];
@@ -35,6 +36,8 @@ export function Cart({
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [processingProductName, setProcessingProductName] = useState<string | null>(null);
const {showFullRefreshButton} = useGlobalZustandState((state) => state);
const visibleProducts = products.slice(0, visibleCount);
const remaining = products.length - visibleProducts.length;
@@ -82,6 +85,7 @@ export function Cart({
<div>
<SummaryTitle>
Ваша корзина на <Green>{days} {declension(days, ['день', 'дня', 'дней'])}</Green>
{showFullRefreshButton && <AbsoluteRefreshButton>Начать заново</AbsoluteRefreshButton>}
</SummaryTitle>
<SummaryDetails $hidden={isLoading}>
@@ -141,13 +145,47 @@ export function Cart({
<ProductModal
product={selectedProduct}
onClose={() => setSelectedProduct(null)}
onReplace={handleReplace}
onAnother={() => undefined}
onReplace={(product) => {
handleReplace(product);
setSelectedProduct(null);
}}
onDelete={(product) => {
handleDelete(product);
setSelectedProduct(null);
}}
/>
</>
);
}
const AbsoluteRefreshButton = styled.button`
background-color: ${({ theme }) => theme.colors.green};
height: 32px;
color: #ffffff;
border: none;
padding: 14px;
border-radius: 12px;
font-size: 16px;
font-weight: 370;
line-height: 20px;
cursor: pointer;
transition: background 0.2s;
display: flex;
align-items: center;
position: absolute;
right: 0;
top: 0;
&:hover,
&:focus {
background-color: ${({ theme }) => theme.colors.greenHover};
}
&:active {
background-color: #4A8D19;
}
`
const Summary = styled.div`
display: flex;
justify-content: space-between;
@@ -155,6 +193,7 @@ const Summary = styled.div`
margin-bottom: 24px;
flex-shrink: 0;
width: 100%;
position: relative;
`;
const SummaryTitle = styled.div`
@@ -207,6 +246,7 @@ const Loader = styled.div<{ $hidden: boolean, "data-display-text": string }>`
color: transparent;
background-clip: text;
position: absolute;
top:32px;
visibility: ${({ $hidden }) => ($hidden ? 'hidden' : 'visible')};
&:before,
+8 -1
View File
@@ -1,5 +1,7 @@
import styled from 'styled-components';
import {BagIcon} from "../Icons/BagIcon";
import {useState} from "react";
import {OrderPopup} from "./OrderPopup";
interface CartFooterProps {
@@ -8,6 +10,8 @@ interface CartFooterProps {
}
export function CartFooter({ isVisible, totalPrice }: CartFooterProps) {
const [showCompletePopup, setShowCompletePopup] = useState(false);
return (
<Footer $visible={isVisible}>
<TotalWrap>
@@ -23,9 +27,12 @@ export function CartFooter({ isVisible, totalPrice }: CartFooterProps) {
</div>
</TotalWrap>
<CheckoutButton type="button">
<CheckoutButton type="button" onClick={() => setShowCompletePopup(true)}>
Оформить заказ
</CheckoutButton>
{showCompletePopup && <>
<OrderPopup setShowCompletePopup={setShowCompletePopup} />
</>}
</Footer>
);
}
+55 -31
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import {useState} from 'react';
import styled from 'styled-components';
import { getProductAmount } from '../../utils/product';
import type { Product } from '../../types/cart';
import {getProductAmount} from '../../utils/product';
import type {Product} from '../../types/cart';
interface CartItemProps {
product: Product;
@@ -23,7 +23,7 @@ export function CartItem({
return (
<Item $processing={isProcessing}>
<ItemImage type="button" onClick={onOpen}>
<img src={product.image} alt="" />
<img src={product.image} alt=""/>
</ItemImage>
<ItemDetails>
@@ -42,20 +42,24 @@ export function CartItem({
</MenuButton>
{isMenuOpen && (
<Dropdown>
<ul>
<li>
<button type="button" onClick={onReplace}>
Заменить
</button>
</li>
<li>
<button type="button" onClick={onDelete}>
Удалить
</button>
</li>
</ul>
</Dropdown>
<>
<Overlay onClick={() => setIsMenuOpen(false)}>
</Overlay>
<Dropdown>
<ul>
<li>
<button type="button" onClick={onReplace}>
Заменить
</button>
</li>
<li>
<button type="button" onClick={onDelete}>
Удалить
</button>
</li>
</ul>
</Dropdown>
</>
)}
</MenuWrapper>
</Item>
@@ -64,13 +68,14 @@ export function CartItem({
const Item = styled.li<{ $processing: boolean }>`
display: flex;
align-items: center;
flex-direction: row;
align-items: stretch;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
position: relative;
transition: opacity 0.4s, transform 0.4s;
opacity: ${({ $processing }) => ($processing ? 0.5 : 1)};
pointer-events: ${({ $processing }) => ($processing ? 'none' : 'auto')};
opacity: ${({$processing}) => ($processing ? 0.5 : 1)};
pointer-events: ${({$processing}) => ($processing ? 'none' : 'auto')};
`;
const ItemImage = styled.button`
@@ -95,22 +100,28 @@ const ItemImage = styled.button`
`;
const ItemDetails = styled.div`
flex: 1;
flex: 1 1 100%;
display: flex;
justify-content: space-between;
flex-direction: column;
`;
const ItemName = styled.div`
font-size: 15px;
color: ${({ theme }) => theme.colors.darkGreen};
color: ${({theme}) => theme.colors.darkGreen};
margin-bottom: 4px;
`;
const ItemPrice = styled.div`
font-size: 14px;
color: ${({ theme }) => theme.colors.gray};
color: ${({theme}) => theme.colors.gray};
`;
const MenuWrapper = styled.div`
position: relative;
width: 26px;
display: flex;
align-self: flex-start;
`;
const MenuButton = styled.button`
@@ -123,23 +134,34 @@ const MenuButton = styled.button`
letter-spacing: 2px;
border-radius: 4px;
transition: 0.2s;
position: absolute;
top: 0; right: 0;
&:hover {
background: #f3f4f6;
color: ${({ theme }) => theme.colors.darkGreen};
color: ${({theme}) => theme.colors.darkGreen};
}
`;
const Overlay = styled.div`
position: fixed;
display: flex;
inset: 0;
background-color: rgba(0, 0, 0, 0);
justify-content: center;
align-items: center;
z-index: 9;
`;
const Dropdown = styled.div`
position: absolute;
right: 0;
top: 100%;
background: #ffffff;
border: 1px solid #eee;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
box-shadow: 0 11px 29px rgba(0, 0, 0, 0.06);
min-width: 150px;
padding: 6px 0;
padding: 10px;
z-index: 10;
margin-top: 4px;
@@ -151,15 +173,17 @@ const Dropdown = styled.div`
width: 100%;
padding: 10px 16px;
font-size: 14px;
color: ${({ theme }) => theme.colors.darkGreen};
color: ${({theme}) => theme.colors.darkGreen};
cursor: pointer;
transition: 0.2s;
background: transparent;
border: 0;
text-align: left;
border-radius: 8px;
text-align: center;
&:hover {
background: #f3f4f6;
background: #5EA12D;
color: #fff;
}
}
`;
+203
View File
@@ -0,0 +1,203 @@
import React, { useEffect, useRef, useCallback } from 'react';
import styled from 'styled-components';
// Тип для одной частицы
interface Particle {
x: number;
y: number;
vx: number; // скорость по X
vy: number; // скорость по Y
radius: number; // начальный радиус
hue: number; // оттенок (0–360)
life: number; // оставшееся время жизни (в кадрах)
maxLife: number; // полное время жизни
}
interface CelebrationCanvasProps {
/** Включена ли анимация */
active?: boolean;
/** Длительность анимации в миллисекундах (по умолчанию 3000) */
duration?: number;
/** Количество частиц (по умолчанию 150) */
particleCount?: number;
/** Колбэк по окончании анимации */
onComplete?: () => void;
}
// Стилизованный контейнер – фиксирован на весь экран, поверх всего (z-index: 5),
// но не перехватывает клики (pointer-events: none)
const Container = styled.div`
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
z-index: 6;
pointer-events: none;
`;
const StyledCanvas = styled.canvas`
display: block;
width: 100%;
height: 100%;
background: transparent; /* прозрачный фон */
z-index: 6;
`;
const CelebrationCanvas: React.FC<CelebrationCanvasProps> = ({
active = true,
duration = 3000,
particleCount = 150,
onComplete,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const particlesRef = useRef<Particle[]>([]);
const animationRef = useRef<number|undefined>(undefined);
const startTimeRef = useRef<number>(0);
// Основной цикл анимации
const animate = useCallback(
(timestamp: number) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const elapsed = timestamp - startTimeRef.current;
// Завершаем по таймеру
if (elapsed > duration) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (onComplete) onComplete();
return;
}
// Очищаем canvas (прозрачный фон)
ctx.clearRect(0, 0, canvas.width, canvas.height);
const particles = particlesRef.current;
// Обновляем и рисуем каждую частицу
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i];
p.x += p.vx;
p.y += p.vy;
p.life -= 1;
// Удаляем мёртвые или вышедшие за границы частицы
if (
p.life <= 0 ||
p.x < 0 ||
p.x > canvas.width ||
p.y < 0 ||
p.y > canvas.height
) {
particles.splice(i, 1);
continue;
}
// Прозрачность и размер зависят от оставшегося времени жизни
const alpha = Math.min(1, p.life / p.maxLife);
const radius = p.radius * (0.3 + 0.7 * alpha);
ctx.beginPath();
ctx.arc(p.x, p.y, radius, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${p.hue}, 100%, 60%, ${alpha})`;
ctx.fill();
}
// Если частицы кончились раньше таймера – завершаем
if (particles.length === 0) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (onComplete) onComplete();
return;
}
// Продолжаем анимацию
animationRef.current = requestAnimationFrame(animate);
},
[duration, onComplete]
);
// Запуск / остановка анимации при изменении active
useEffect(() => {
if (!active) {
// Останавливаем анимацию и очищаем canvas
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = undefined;
}
const canvas = canvasRef.current;
if (canvas) {
const ctx = canvas.getContext('2d');
if (ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
}
return;
}
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// Подгоняем размер canvas под окно
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
};
window.addEventListener('resize', resize);
resize();
// Генерируем частицы из центра
const centerX = canvas.width / 2;
const centerY = canvas.height / 2;
const newParticles: Particle[] = [];
for (let i = 0; i < particleCount; i++) {
const angle = Math.random() * 2 * Math.PI;
const speed = 2 + Math.random() * 7;
const radius = 2 + Math.random() * 4;
const hue = Math.random() * 360;
const life = 40 + Math.random() * 80; // количество кадров жизни
newParticles.push({
x: centerX,
y: centerY,
vx: Math.cos(angle) * speed,
vy: Math.sin(angle) * speed,
radius,
hue,
life,
maxLife: life,
});
}
particlesRef.current = newParticles;
// Запоминаем время старта
startTimeRef.current = performance.now();
// Отменяем предыдущий кадр, если был
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
animationRef.current = requestAnimationFrame(animate);
// Cleanup при размонтировании или изменении зависимостей
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
animationRef.current = undefined;
}
window.removeEventListener('resize', resize);
};
}, [active, particleCount, animate]);
// Компонент всегда рендерится, но анимация запускается только при active=true
return (
<Container>
<StyledCanvas ref={canvasRef} />
</Container>
);
};
export default CelebrationCanvas;
@@ -0,0 +1,83 @@
import styled from "styled-components";
import {TextInput} from "../components/TextInput";
import {useState} from "react";
import CelebrationCanvas from "./CelebrationCanvas";
import {createPortal} from "react-dom";
import {subscribeOnFinish} from "../../api/promoApi";
export const FinishPopupContent = ({setShowCompletePopup}: any) => {
const [email, setEmail] = useState<string>("");
const [buttonContent, setButtonContent] = useState<string>("Получить подарок");
return <FinishPopupContentWrap>
<img src="/images/broc-gifts.png" alt=""/>
<h2>Спасибо!</h2>
<p>
Вы помогли нам протестировать автокорзину. Сейчас сервис ещё разрабатывается, поэтому оформить заказ пока нельзя.
Оставьте адрес электронной почты, и после запуска мы подарим вам <strong>3 бесплатные корзины</strong>.
</p>
<TextInput placeholder={'Почта'} value={email} onChange={setEmail} />
<FinishButton onClick={() => {
if (buttonContent === 'Получить подарок') {
setButtonContent('...')
subscribeOnFinish(email).then((data) => {
if (data && data.status === 200) {
setButtonContent('Письмо отправлено')
} else {
setButtonContent('Возникла ошибка')
}
}).catch(() => {
setButtonContent('Возникла ошибка')
})
}
}}>{buttonContent}</FinishButton>
{createPortal(<CelebrationCanvas duration={5000} particleCount={300} />, document.body)}
</FinishPopupContentWrap>;
};
const FinishPopupContentWrap = styled.div`
padding: 20px;
img {
max-width: 100%;
}
h2 {
margin-top: 24px;
margin-bottom: 8px;
font-size: 32px;
line-height: 48px;
font-weight: 450;
text-align: center;
}
p {
font-size: 16px;
line-height: 19px;
font-weight: 360;
text-align: center;
margin-bottom: 24px;
}
`;
const FinishButton = styled.button`
background-color: ${({ theme }) => theme.colors.green};
height: 54px;
width: 100%;
color: #ffffff;
border: none;
padding: 14px 32px;
border-radius: 12px;
font-size: 16px;
font-weight: 370;
line-height: 20px;
cursor: pointer;
transition: background 0.2s;
margin-top: 16px;
&:hover,
&:focus {
background-color: ${({ theme }) => theme.colors.greenHover};
}
&:active {
background-color: #4A8D19;
}
`;
+128
View File
@@ -0,0 +1,128 @@
import styled from "styled-components";
import {useState} from "react";
import {FinishPopupContent} from "./FinishPopupContent";
export const OrderPopup = ({setShowCompletePopup}: any) => {
const [showFinal, setShowFinal] = useState(false);
return <Overlay onClick={() => setShowCompletePopup(false)}>
<Window onClick={event => event.stopPropagation()}>
<CloseButton type="button" aria-label="Закрыть" onClick={() => setShowCompletePopup(false)}>
&times;
</CloseButton>
{showFinal ? <FinishPopupContent setShowCompletePopup={setShowCompletePopup} /> :
<ModalContent>
<ServiceFeeTitle>Стоимость составления корзины</ServiceFeeTitle>
<ServiceFee>199 ₽</ServiceFee>
<ServiceFeeDescription>Включена в итоговую стоимость заказа</ServiceFeeDescription>
<ButtonBlock>
<ActiveButton onClick={() => setShowFinal(true)}>Продолжить</ActiveButton>
<Button onClick={() => setShowCompletePopup(false)}>Вернуться к корзине</Button>
</ButtonBlock>
</ModalContent>}
</Window>
</Overlay>
}
const Overlay = styled.div`
position: fixed;
display: flex;
inset: 0;
background-color: rgba(0, 0, 0, 0.4);
justify-content: center;
align-items: center;
z-index: 4;
`;
const Window = styled.div`
background-color: #ffffff;
border-radius: 12px;
width: auto;
max-width: 535px;
padding: 20px 24px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 9;
`;
const CloseButton = styled.button`
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
width: 24px;
height: 24px;
font-weight: 300;
line-height: 12px;
color: #CEDFD0;
font-size: 24px;
cursor: pointer;
padding: 5px;
transition: 0.2s;
&:hover {
color: #ACBAAE;
}
`;
const ModalContent = styled.div`
padding: 20px;
color: #0C3B2E;
max-width: 335px;
`;
const ButtonBlock = styled.div`
margin-top: 24px;
display: flex;
gap: 16px;
flex-direction: column;
`;
const Button = styled.div`
height: 54px;
border-radius: 12px;
text-align: center;
background: #fff;
color: #5EA12D;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
line-height: 16px;
cursor: pointer;
`;
const ActiveButton = styled(Button)`
background-color: #5EA12D;
color: #fff;
font-size: 16px;
line-height: 20px;
&:hover,
&:focus {
background-color: ${({theme}) => theme.colors.greenHover};
}
&:active {
background-color: #4A8D19;
}
`;
const ServiceFeeTitle = styled.div`
font-size: 20px;
line-height: 24px;
font-weight: 450;
text-align: center;
`;
const ServiceFee = styled.div`
color: #5EA12D;
font-size: 32px;
line-height: 38px;
font-weight: 420;
text-align: center;
margin-top: 8px;
margin-bottom: 8px;
`
const ServiceFeeDescription = styled.div`
font-size: 16px;
line-height: 19px;
font-weight: 350;
text-align: center;
`
+4 -4
View File
@@ -7,14 +7,14 @@ interface ProductModalProps {
product: Product | null;
onClose: () => void;
onReplace: (product: Product) => void;
onAnother: (product: Product) => void;
onDelete: (product: Product) => void;
}
export function ProductModal({
product,
onClose,
onReplace,
onAnother,
onDelete,
}: ProductModalProps) {
if (!product) return null;
@@ -43,10 +43,10 @@ export function ProductModal({
href="#"
onClick={event => {
event.preventDefault();
onAnother(product);
onDelete(product);
}}
>
Ещё вариант
Удалить
</AnotherButton>
</Window>
</Overlay>
@@ -16,7 +16,7 @@ export function CategoriesSection({
onLocalChange,
onSaved,
}: CategoriesSectionProps) {
const [expanded, setExpanded] = useState(false);
const [expanded, setExpanded] = useState(true);
const [loadingCategories, setLoadingCategories] = useState<string[]>([]);
const maxVisibleCategories = 7;
@@ -62,7 +62,7 @@ export function CategoriesSection({
<Block>
<Header>
<h3>Что добавить в корзину?</h3>
<p>Выберите группы продуктов, которые хотите видеть в корзине</p>
<p>Выберите группы продуктов, которые должны обязательно попасть в корзину</p>
</Header>
<Wrapper>
+11 -10
View File
@@ -51,7 +51,11 @@ export function FamilySection({
<Info>
<Name>{user.name}</Name>
<Age>{user.age} {declension(user.age, ['год', 'года', 'лет'])}</Age>
<Age>
{user.age} {declension(user.age, ['год', 'года', 'лет'])}
<b> • </b>
<span>{user.gender === 'm' ? 'Мужской' : 'Женский'}</span>
</Age>
</Info>
<DeleteButton
@@ -129,23 +133,19 @@ const FamilyMember = styled.button`
}
&:nth-child(n+1) ${() => Avatar} {
background-color: #B4AAFF;
}
&:nth-child(n+2) ${() => Avatar} {
background-color: #CEE0FF;
}
&:nth-child(n+3) ${() => Avatar} {
&:nth-child(2n+2) ${() => Avatar} {
background-color: #E6F696;
}
&:nth-child(n+4) ${() => Avatar} {
background-color: #FFF488;
&:nth-child(3n+3) ${() => Avatar} {
background-color: #fff488;
}
&:nth-child(n+5) ${() => Avatar} {
background-color: #FFD623;
&:nth-child(4n+4) ${() => Avatar} {
background-color: #ffd623;
}
`;
@@ -179,6 +179,7 @@ const Name = styled.div`
const Age = styled.div`
font-size: 14px;
font-weight: 350;
color: ${({ theme }) => theme.colors.gray};
`;
+127 -97
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react';
import {useEffect, useState} from 'react';
import styled from 'styled-components';
import type { Gender, User } from '../../types/user';
import { TagsInput } from './TagsInput';
import type {Gender, User} from '../../types/user';
import {Tag, TagsInput, TagsWrap} from './TagsInput';
interface PersonModalProps {
isOpen: boolean;
@@ -17,7 +17,7 @@ interface PersonForm {
age: string;
weight: string;
height: string;
avoid: string[];
avoided: string[];
favorite: string[];
}
@@ -27,7 +27,7 @@ const defaultForm: PersonForm = {
age: '',
weight: '',
height: '',
avoid: [],
avoided: [],
favorite: [],
};
@@ -39,6 +39,7 @@ export function PersonModal({
onSave,
}: PersonModalProps) {
const [form, setForm] = useState<PersonForm>(defaultForm);
const [showExpanded, setShowExpanded] = useState<'' | 'avoid' | 'favorite'>('');
useEffect(() => {
if (!isOpen) return;
@@ -50,7 +51,7 @@ export function PersonModal({
age: String(user.age),
weight: user.weight ? String(user.weight) : '',
height: user.height ? String(user.height) : '',
avoid: user.avoid ?? [],
avoided: user.avoided ?? [],
favorite: user.favorite ?? [],
});
} else {
@@ -70,7 +71,7 @@ export function PersonModal({
function handleAvoidChange(tags: string[]) {
setForm(current => ({
...current,
avoid: tags,
avoided: tags,
favorite: current.favorite.filter(tag => !tags.includes(tag)),
}));
}
@@ -79,7 +80,7 @@ export function PersonModal({
setForm(current => ({
...current,
favorite: tags,
avoid: current.avoid.filter(tag => !tags.includes(tag)),
avoided: current.avoided.filter(tag => !tags.includes(tag)),
}));
}
@@ -103,105 +104,133 @@ export function PersonModal({
age,
weight: form.weight ? Number(form.weight) : undefined,
height: form.height ? Number(form.height) : undefined,
avoid: form.avoid,
avoided: form.avoided,
favorite: form.favorite,
});
}
return (
<Overlay onClick={onClose}>
<Content onClick={event => event.stopPropagation()}>
<Header>
<h2>{user ? 'Редактировать человека' : 'Добавить человека'}</h2>
<CloseButton type="button" onClick={onClose}>
×
</CloseButton>
</Header>
<FormGroup>
<label>Имя</label>
<input
type="text"
placeholder="Имя"
value={form.name}
onChange={event => updateForm('name', event.target.value)}
/>
</FormGroup>
<FormGroup>
<label>Пол</label>
<GenderToggles>
<GenderButton
type="button"
$active={form.gender === 'f'}
onClick={() => updateForm('gender', 'f')}
>
Женский
</GenderButton>
<GenderButton
type="button"
$active={form.gender === 'm'}
onClick={() => updateForm('gender', 'm')}
>
Мужской
</GenderButton>
</GenderToggles>
</FormGroup>
<FormRow>
{showExpanded ? (
<Content onClick={event => event.stopPropagation()}>
<Header>
<h2>{showExpanded == 'favorite' ? 'Любимые' : 'Не добавлять'}</h2>
<CloseButton type="button" onClick={() => setShowExpanded('')}>
×
</CloseButton>
</Header>
<FormGroup>
<label>Возраст</label>
<input
type="number"
value={form.age}
onChange={event => updateForm('age', event.target.value)}
/>
<Hint>от 7 лет</Hint>
<TagsWrap>
{((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).map(tag => (
<Tag key={tag} $type={showExpanded}>
{tag}
<button type="button"
onClick={() => (showExpanded == 'favorite' ? handleFavoriteChange : handleAvoidChange)(
((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).filter(item => item != tag)
)}>
×
</button>
</Tag>
))}
</TagsWrap>
</FormGroup>
</Content>
) : (
<Content onClick={event => event.stopPropagation()}>
<Header>
<h2>{user ? 'Редактировать человека' : 'Добавить человека'}</h2>
<CloseButton type="button" onClick={onClose}>
×
</CloseButton>
</Header>
<FormGroup>
<label>Вес</label>
<label>Имя</label>
<input
type="number"
value={form.weight}
onChange={event => updateForm('weight', event.target.value)}
type="text"
placeholder="Имя"
value={form.name}
onChange={event => updateForm('name', event.target.value)}
/>
</FormGroup>
<FormGroup>
<label>Рост</label>
<input
type="number"
value={form.height}
onChange={event => updateForm('height', event.target.value)}
/>
<label>Пол</label>
<GenderToggles>
<GenderButton
type="button"
$active={form.gender === 'f'}
onClick={() => updateForm('gender', 'f')}
>
Женский
</GenderButton>
<GenderButton
type="button"
$active={form.gender === 'm'}
onClick={() => updateForm('gender', 'm')}
>
Мужской
</GenderButton>
</GenderToggles>
</FormGroup>
</FormRow>
<PrefsGrid>
<TagsInput
title="Запрещённые"
type="avoid"
value={form.avoid}
availableTags={availableTags}
excludedTags={form.favorite}
onChange={handleAvoidChange}
/>
<FormRow>
<FormGroup>
<label>Возраст</label>
<input
type="number"
value={form.age}
onChange={event => updateForm('age', event.target.value)}
/>
<Hint>от 7 лет</Hint>
</FormGroup>
<TagsInput
title="Любимые"
type="favorite"
value={form.favorite}
availableTags={availableTags}
excludedTags={form.avoid}
onChange={handleFavoriteChange}
/>
</PrefsGrid>
<FormGroup>
<label>Вес</label>
<input
type="number"
value={form.weight}
onChange={event => updateForm('weight', event.target.value)}
/>
</FormGroup>
<SaveButton type="button" onClick={handleSave}>
Сохранить
</SaveButton>
</Content>
<FormGroup>
<label>Рост</label>
<input
type="number"
value={form.height}
onChange={event => updateForm('height', event.target.value)}
/>
</FormGroup>
</FormRow>
<PrefsGrid>
<TagsInput
title="Не добавлять"
type="avoid"
value={form.avoided}
availableTags={availableTags}
excludedTags={form.favorite}
onChange={handleAvoidChange}
onExpand={() => setShowExpanded('avoid')}
/>
<TagsInput
title="Любимые"
type="favorite"
value={form.favorite}
availableTags={availableTags}
excludedTags={form.avoided}
onChange={handleFavoriteChange}
onExpand={() => setShowExpanded('favorite')}
/>
</PrefsGrid>
<SaveButton type="button" onClick={handleSave}>
Сохранить
</SaveButton>
</Content>
)}
</Overlay>
);
}
@@ -238,7 +267,7 @@ const Header = styled.div`
h2 {
font-size: 20px;
font-weight: 600;
color: ${({ theme }) => theme.colors.darkGreen};
color: ${({theme}) => theme.colors.darkGreen};
}
`;
@@ -251,7 +280,7 @@ const CloseButton = styled.button`
border: 0;
&:hover {
color: ${({ theme }) => theme.colors.darkGreen};
color: ${({theme}) => theme.colors.darkGreen};
}
`;
@@ -262,7 +291,7 @@ const FormGroup = styled.div`
label {
display: block;
font-size: 14px;
color: ${({ theme }) => theme.colors.darkGreen};
color: ${({theme}) => theme.colors.darkGreen};
margin-bottom: 6px;
}
@@ -276,7 +305,7 @@ const FormGroup = styled.div`
transition: 0.2s;
&:focus {
border-color: ${({ theme }) => theme.colors.green};
border-color: ${({theme}) => theme.colors.green};
}
}
`;
@@ -298,7 +327,7 @@ const FormRow = styled.div`
const Hint = styled.span`
display: block;
font-size: 12px;
color: ${({ theme }) => theme.colors.gray};
color: ${({theme}) => theme.colors.gray};
margin-top: 4px;
`;
@@ -311,13 +340,13 @@ const GenderToggles = styled.div`
const GenderButton = styled.button<{ $active: boolean }>`
flex: 1;
padding: 10px;
border: 1px solid ${({ $active }) => ($active ? '#e9f6d9' : '#e1e1e1')};
border: 1px solid ${({$active}) => ($active ? '#e9f6d9' : '#e1e1e1')};
border-radius: 8px;
background: ${({ $active }) => ($active ? '#e9f6d9' : '#ffffff')};
background: ${({$active}) => ($active ? '#e9f6d9' : '#ffffff')};
font-size: 14px;
cursor: pointer;
transition: 0.2s;
font-weight: ${({ $active }) => ($active ? 500 : 400)};
font-weight: ${({$active}) => ($active ? 500 : 400)};
`;
const PrefsGrid = styled.div`
@@ -326,6 +355,7 @@ const PrefsGrid = styled.div`
margin-bottom: 20px;
border-top: 1px solid #f0f0f0;
padding-top: 16px;
min-height: 200px;
@media (max-width: 600px) {
flex-direction: column;
@@ -335,7 +365,7 @@ const PrefsGrid = styled.div`
const SaveButton = styled.button`
width: 100%;
padding: 12px;
background: ${({ theme }) => theme.colors.green};
background: ${({theme}) => theme.colors.green};
color: #fff;
border: none;
border-radius: 12px;
+71 -4
View File
@@ -8,6 +8,7 @@ interface TagsInputProps {
availableTags: string[];
excludedTags: string[];
onChange: (tags: string[]) => void;
onExpand: () => void;
}
export function TagsInput({
@@ -17,6 +18,7 @@ export function TagsInput({
availableTags,
excludedTags,
onChange,
onExpand,
}: TagsInputProps) {
const [query, setQuery] = useState('');
@@ -69,7 +71,7 @@ export function TagsInput({
)}
<TagsWrap>
{value.map(tag => (
{value.slice(0, 4).map(tag => (
<Tag key={tag} $type={type}>
{tag}
<button type="button" onClick={() => removeTag(tag)}>
@@ -77,11 +79,53 @@ export function TagsInput({
</button>
</Tag>
))}
{value.length > 4 && <TagCollapse $type={'favorite'} $collapsed={true} onClick={() => onExpand()}>
+{value.length - 4}
<svg viewBox="0 0 24 24" fill="none">
<polyline points="18 15 12 9 6 15" />
</svg>
</TagCollapse>}
</TagsWrap>
</Column>
);
}
const Overlay = styled.div`
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
`;
const Content = styled.div`
background: #ffffff;
border-radius: 24px;
width: 100%;
max-width: 440px;
padding: 24px;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.2);
max-height: 95vh;
overflow-y: auto;
`;
const Header = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
h2 {
font-size: 20px;
font-weight: 600;
color: ${({ theme }) => theme.colors.darkGreen};
}
`;
const Column = styled.div`
flex: 1;
position: relative;
@@ -138,13 +182,13 @@ const Autocomplete = styled.div`
}
`;
const TagsWrap = styled.div`
export const TagsWrap = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px;
`;
const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>`
export const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>`
display: inline-flex;
align-items: center;
padding: 4px 10px;
@@ -166,4 +210,27 @@ const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>`
opacity: 1;
}
}
`;
`;
const TagCollapse = styled(Tag)<{$collapsed: boolean}>`
border: 1px solid #E7EDE7;
background: transparent;
color: ${({ theme }) => theme.colors.darkGreen};
cursor: pointer;
transition: 0.2s;
&:hover {
background-color: #f0f4c3;
}
svg {
width: 18px;
height: 18px;
stroke: ${({ theme }) => theme.colors.darkGreen};
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transition: transform 0.3s ease;
transform: ${({ $collapsed }) => ($collapsed ? 'rotate(180deg)' : 'none')};
}
`
+162
View File
@@ -0,0 +1,162 @@
import React, { useState, ChangeEvent } from 'react';
import styled from 'styled-components';
// --- Типы ---
interface SliderProps {
min?: number;
max?: number;
step?: number;
defaultValue?: number;
onChange?: (value: number) => void;
}
// --- Стили (Styled-Components) ---
const Container = styled.div`
width: 100%;
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
`;
const SliderWrapper = styled.div`
position: relative;
width: 100%;
height: 24px; // Высота для кликабельной области бегунка
display: flex;
align-items: center;
`;
const Track = styled.div`
position: relative;
width: 100%;
height: 12px;
background-color: #f0f7ec; // Светло-зеленый фон
border-radius: 6px;
cursor: pointer;
`;
const Fill = styled.div<{ $percent: number }>`
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: #5b9e30; // Зеленый цвет заполнения
width: ${({ $percent }) => $percent}%;
// Скругление только слева, если не достигнут максимум
border-radius: ${({ $percent }) =>
$percent >= 100 ? '6px' : $percent <= 0 ? '6px' : '6px 0 0 6px'
};
pointer-events: none;
`;
const Thumb = styled.div<{ $percent: number }>`
position: absolute;
top: 50%;
left: ${({ $percent }) => $percent}%;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
background-color: #5b9e30;
border-radius: 50%;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
// Мобильная версия: немного увеличиваем для удобного нажатия пальцем
@media (max-width: 768px) {
width: 28px;
height: 28px;
}
`;
// Прозрачный нативный инпут для обработки перетаскивания и кликов
const RangeInput = styled.input`
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
margin: 0;
`;
const LabelsContainer = styled.div`
position: relative;
width: 100%;
height: 30px;
margin-top: 8px;
`;
const Label = styled.span`
position: absolute;
font-size: 16px;
color: #333333;
@media (max-width: 768px) {
font-size: 14px;
}
`;
const LeftLabel = styled(Label)`
left: 0;
transform: translateX(0);
`;
const RightLabel = styled(Label)`
right: 0;
transform: translateX(0);
`;
const CenterLabel = styled(Label)<{ $percent: number }>`
left: ${({ $percent }) => $percent}%;
transform: translateX(-50%);
`;
// --- Основной Компонент ---
const CustomSlider: React.FC<SliderProps> = ({
min = 30,
max = 170,
step = 10,
defaultValue = 90,
onChange,
}) => {
const [value, setValue] = useState<number>(defaultValue);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const newValue = Number(e.target.value);
setValue(newValue);
if (onChange) onChange(newValue);
};
// Процент заполнения трека (от 0% до 100%)
const percent = ((value - min) / (max - min)) * 100;
return (
<Container>
<SliderWrapper>
<Track>
<Fill $percent={percent} />
<Thumb $percent={percent} />
<RangeInput
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={handleChange}
aria-label="Процентное значение"
/>
</Track>
</SliderWrapper>
<LabelsContainer>
<LeftLabel>{min}%</LeftLabel>
{(percent > 10) && (percent < 90) ? (<CenterLabel $percent={percent}>{value}%</CenterLabel>) : null}
<RightLabel>{max}%</RightLabel>
</LabelsContainer>
</Container>
);
};
export default CustomSlider;
+160
View File
@@ -0,0 +1,160 @@
import React from 'react';
import styled, { css } from 'styled-components';
export type InputState = 'default' | 'success' | 'error' | 'disabled';
interface TextInputProps {
label?: string;
value: string;
onChange: (value: string) => void;
state?: InputState;
placeholder?: string;
allowClear?: boolean;
onClear?: () => void;
}
// --- Стили ---
const Container = styled.div`
display: flex;
flex-direction: column;
gap: 8px;
font-family: Arial, sans-serif;
`;
const Label = styled.label<{ $state: InputState }>`
font-size: 16px;
color: #000;
margin-bottom: 4px;
${({ $state }) => $state === 'disabled' && css`
color: #9e9e9e;
`}
`;
const Wrapper = styled.div<{ $state: InputState; $allowClear: boolean }>`
position: relative;
display: flex;
align-items: center;
border-radius: 6px;
background: #fff;
transition: border-color 0.2s;
/* Default State */
border: 1px solid #e0e0e0;
&:focus-within {
border-color: #b3b3b3;
}
/* Success State */
${({ $state }) => $state === 'success' && css`
border: 2px solid #4CAF50;
`}
/* Error State */
${({ $state }) => $state === 'error' && css`
border: 2px solid #F44336;
`}
/* Disabled State */
${({ $state }) => $state === 'disabled' && css`
background-color: #f2f2f2;
border: 1px solid #e0e0e0;
`}
/* Padding adjustments for clear button */
${({ $allowClear }) => $allowClear && css`
input {
padding-right: 40px;
}
`}
`;
const Input = styled.input<{ $state: InputState }>`
width: 100%;
padding: 12px 16px;
border: none;
outline: none;
background: transparent;
font-size: 16px;
color: #333;
&::placeholder {
color: #999;
}
${({ $state }) => $state === 'disabled' && css`
color: #9e9e9e;
cursor: not-allowed;
`}
`;
const ClearButton = styled.button<{ $state: InputState }>`
position: absolute;
right: 12px;
background: none;
border: none;
font-size: 20px;
color: #9e9e9e;
cursor: pointer;
padding: 0;
line-height: 1;
&:hover {
color: #333;
}
${({ $state }) => $state === 'disabled' && css`
cursor: not-allowed;
color: #c0c0c0;
`}
`;
export const TextInput: React.FC<TextInputProps> = ({
label,
value,
onChange,
state = 'default',
placeholder = 'Текст',
allowClear = false,
onClear,
}) => {
const isDisabled = state === 'disabled';
const handleClear = () => {
if (onClear) {
onClear();
} else {
onChange('');
}
};
return (
<Container>
{label && <Label $state={state}>{label}</Label>}
<Wrapper $state={state} $allowClear={allowClear}>
<Input
type="text"
value={value}
placeholder={placeholder}
disabled={isDisabled}
$state={state}
onChange={(e) => onChange(e.target.value)}
/>
{allowClear && (
<ClearButton
type="button"
onClick={handleClear}
disabled={isDisabled}
$state={state}
aria-label="Очистить"
>
×
</ClearButton>
)}
</Wrapper>
</Container>
);
};