final flow
This commit is contained in:
@@ -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;
|
||||
`;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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)}>
|
||||
×
|
||||
</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;
|
||||
`
|
||||
@@ -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>
|
||||
|
||||
@@ -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};
|
||||
`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')};
|
||||
}
|
||||
`
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user