я.метрика

This commit is contained in:
Zakhar
2026-09-04 18:57:54 +03:00
parent 74f9885677
commit 80824b0494
22 changed files with 597 additions and 279 deletions
+25 -4
View File
@@ -9,7 +9,8 @@ import { CartItem } from './CartItem';
import { CartFooter } from './CartFooter';
import { ProductModal } from './ProductModal';
import {useGlobalZustandState} from "../../hooks/useGlobalZustandState";
import ym from "react-yandex-metrika";
import {NotEnoughPopup} from "./NotEnoughPopup";
import {reachGoal} from "../../hooks/useYM";
interface CartProps {
products: Product[];
@@ -18,6 +19,7 @@ interface CartProps {
isLoading: boolean;
onProductsChange: Dispatch<SetStateAction<Product[]>>;
onComplete: () => void;
onMistake: () => void;
onLoadingChange: (isLoading: boolean) => void;
}
@@ -29,7 +31,9 @@ export function Cart({
onProductsChange,
onComplete,
onLoadingChange,
onMistake,
}: CartProps) {
const [showNotEnoughPopup, setShowNotEnoughPopup] = useState(false);
const [visibleCount, setVisibleCount] = useState(5);
const [displayText, setDisplayText] = useState('');
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
@@ -54,10 +58,11 @@ export function Cart({
onComplete,
onLoadingChange,
setDisplayText,
onMistake,
});
async function handleReplace(product: Product) {
ym('reachGoal', 'ac_product_replace_' + product.name);
reachGoal('ac_product_replace_' + product.name);
setProcessingProductName(product.name);
try {
@@ -69,7 +74,7 @@ export function Cart({
}
async function handleDelete(product: Product) {
ym('reachGoal', 'ac_product_delete_' + product.name);
reachGoal('ac_product_delete_' + product.name);
setProcessingProductName(product.name);
try {
@@ -85,7 +90,7 @@ export function Cart({
<Summary>
<div>
<SummaryTitle>
Ваша корзина на <Green>{days} {declension(days, ['день', 'дня', 'дней'])}</Green>
Ваша закупка на <Green>{days} {declension(days, ['день', 'дня', 'дней'])}</Green>
{showFullRefreshButton && <AbsoluteRefreshButton>Начать заново</AbsoluteRefreshButton>}
</SummaryTitle>
@@ -122,6 +127,11 @@ export function Cart({
onDelete={() => handleDelete(product)}
/>
))}
{products.length === visibleProducts.length && isComplete && !isLoading && <NotEnoughWrapper>
<ShowNotEnough type="button" onClick={() => setShowNotEnoughPopup(true)}>
Не хватило
</ShowNotEnough>
</NotEnoughWrapper>}
</CartList>
{products.length === 0 && (
@@ -155,6 +165,8 @@ export function Cart({
setSelectedProduct(null);
}}
/>
{showNotEnoughPopup && <NotEnoughPopup onClose={() => {setShowNotEnoughPopup(false)}} />}
</>
);
}
@@ -339,4 +351,13 @@ const ShowMore = styled.button`
stroke-linejoin: round;
margin-left: 6px;
}
`;
const NotEnoughWrapper = styled.div`
display: flex;
flex: 1 1 100%;
justify-content: center;
`;
const ShowNotEnough = styled(ShowMore)`
text-align: center;
`;
+4 -3
View File
@@ -3,7 +3,8 @@ import {BagIcon} from "../Icons/BagIcon";
import {useState} from "react";
import {OrderPopup} from "./OrderPopup";
import {useAppSelector} from "../../store";
import ym from "react-yandex-metrika";
import {reachGoal} from "../../hooks/useYM";
interface CartFooterProps {
@@ -31,10 +32,10 @@ export function CartFooter({ isVisible, totalPrice }: CartFooterProps) {
</TotalWrap>
<CheckoutButton type="button" onClick={() => {
ym('reachGoal', 'ac_hvatilo')
reachGoal('ac_hvatilo')
setShowCompletePopup(true)
}}>
{users.length > 1 ? 'Нам' : 'Мне'} хватило
Хватило
</CheckoutButton>
{showCompletePopup && <>
<OrderPopup setShowCompletePopup={setShowCompletePopup} />
+6 -3
View File
@@ -2,7 +2,7 @@ import {useEffect, useState} from 'react';
import styled from 'styled-components';
import {getProductAmount} from '../../utils/product';
import type {Product} from '../../types/cart';
import ym from "react-yandex-metrika";
import {reachGoal} from "../../hooks/useYM";
interface CartItemProps {
product: Product;
@@ -21,8 +21,10 @@ export function CartItem({
}: CartItemProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => {
ym('reachGoal', 'ac_product_replacement_menu_' + product.name);
}, [product.name])
if (isMenuOpen) {
reachGoal('ac_product_replacement_menu_' + product.name);
}
}, [product.name, isMenuOpen])
return (
<Item $processing={isProcessing}>
@@ -148,6 +150,7 @@ const MenuButton = styled.button`
`;
const Overlay = styled.div`
height: 100vh;
position: fixed;
display: flex;
inset: 0;
+1
View File
@@ -32,6 +32,7 @@ const Overlay = styled.div`
align-items: center;
z-index: 4;
overflow: auto;
height: 100vh;
`;
const Window = styled.div`
background-color: #ffffff;
+161
View File
@@ -0,0 +1,161 @@
import styled, {css} from "styled-components";
import {TextInput} from "../components/TextInput";
import {useState} from "react";
import FeedbackTextarea from "../components/Textarea";
import {subscribeOnFail} from "../../api/promoApi";
export const NotEnoughPopup = ({onClose}: any) => {
const [email, setEmail] = useState<string>("");
const [value, setValue] = useState('');
const [sent, setSent] = useState(false);
return <Overlay>
<Window>
<CloseButton type="button" aria-label="Закрыть" onClick={() => onClose()}>
&times;
</CloseButton>
<ModalContent>
{sent ? <>
<h2>Сообщение отправлено</h2>
<V8/>
<p>
Спасибо, ваша помощь крайне важна для нас, всё записали!
</p>
<img src="/images/broc-fail.png" alt=""/>
</> : <>
<h2>Чего-то не хватило?</h2>
<V8/>
<p>
Расскажите, что не так с текущей корзиной — мы постараемся учесть ваши пожелания. За отзыв
подарим <strong>3 бесплатные корзины</strong>
</p>
<V24/>
<TextInput placeholder={'Почта'} value={email} onChange={setEmail}/>
<V16/>
<FeedbackTextarea maxLength={400} value={value} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
setValue(e.target.value);
}}></FeedbackTextarea>
<V16/>
<V24/>
<ActiveButton onClick={() => subscribeOnFail(email, value).then(() => {setSent(true)})}>Отправить</ActiveButton>
</>}
</ModalContent>
</Window>
</Overlay>
};
const V8 = styled.div`height: 8px;`;
const V16 = styled.div`height: 16px;`;
const V24 = styled.div`height: 24px;`;
const Overlay = styled.div`
height: 100vh;
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: 515px;
max-width: 100%;
padding: 20px 24px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
position: relative;
z-index: 9;
max-height: 90vh;
overflow: auto;
`;
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;
}
${({theme}) => theme.media.mobile(css`
top: 40px;
`)};
`;
const ModalContent = styled.div`
padding: 20px;
color: #0C3B2E;
box-sizing: content-box;
h2 {
font-size: 20px;
text-align: center;
}
p {
font-size: 16px;
text-align: center;
}
img {
max-width: 100%;
}
`;
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;
&:hover {
background-color: #F7FBF6;
}
&:active,
&:focus {
background-color: #EEF8EB;
outline: none;
}
`;
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;
}
`;
+5 -3
View File
@@ -1,7 +1,8 @@
import styled, {css} from "styled-components";
import {useState} from "react";
import {FinishPopupContent} from "./FinishPopupContent";
import ym from "react-yandex-metrika";
import {reachGoal} from "../../hooks/useYM";
export const OrderPopup = ({setShowCompletePopup}: any) => {
const [showFinal, setShowFinal] = useState(false);
@@ -18,11 +19,11 @@ export const OrderPopup = ({setShowCompletePopup}: any) => {
<ServiceFeeDescription>Включена в итоговую стоимость заказа</ServiceFeeDescription>
<ButtonBlock>
<ActiveButton onClick={() => {
ym('reachGoal', 'ac_order_button')
reachGoal('ac_order_button')
setShowFinal(true)
}}>Продолжить</ActiveButton>
<Button onClick={() => {
ym('reachGoal', 'ac_no_order_button')
reachGoal('ac_no_order_button')
setShowCompletePopup(false)
}}>Вернуться к корзине</Button>
</ButtonBlock>
@@ -32,6 +33,7 @@ export const OrderPopup = ({setShowCompletePopup}: any) => {
}
const Overlay = styled.div`
height: 100vh;
position: fixed;
display: flex;
inset: 0;
+4 -2
View File
@@ -3,7 +3,8 @@ import { formatCurrency } from '../../utils/formatCurrency';
import { getLargeProductImage } from '../../utils/product';
import type { Product } from '../../types/cart';
import {useEffect} from "react";
import ym from "react-yandex-metrika";
import {reachGoal} from "../../hooks/useYM";
interface ProductModalProps {
product: Product | null;
@@ -20,7 +21,7 @@ export function ProductModal({
}: ProductModalProps) {
useEffect(() => {
if (product !== null) {
ym('reachGoal', 'ac_open_popup')
reachGoal('ac_open_popup')
}
}, [product]);
@@ -62,6 +63,7 @@ export function ProductModal({
}
const Overlay = styled.div`
height: 100vh;
position: fixed;
display: flex;
inset: 0;
@@ -2,7 +2,8 @@ import { useMemo, useState } from 'react';
import styled from 'styled-components';
import { setOption } from '../../api/promoApi';
import { useDebounceBatch } from '../../hooks/useDebounceBatch';
import ym from "react-yandex-metrika";
import {reachGoal} from "../../hooks/useYM";
interface CategoriesSectionProps {
categories: string[];
@@ -32,7 +33,7 @@ export function CategoriesSection({
const sendUpdatedCategories = useDebounceBatch<string>(async categoryNames => {
let nextCategories = [...selectedCategories];
ym('reachGoal', 'ac_set_categories');
reachGoal('ac_set_categories');
categoryNames.forEach(categoryName => {
if (nextCategories.includes(categoryName)) {
nextCategories = nextCategories.filter(item => item !== categoryName);
+26 -12
View File
@@ -2,7 +2,8 @@ import {useEffect, useState} from 'react';
import styled from 'styled-components';
import type {Gender, User} from '../../types/user';
import {Tag, TagsInput, TagsWrap} from './TagsInput';
import ym from "react-yandex-metrika";
import {reachGoal} from "../../hooks/useYM";
interface PersonModalProps {
isOpen: boolean;
@@ -46,7 +47,7 @@ export function PersonModal({
if (!isOpen) return;
if (user) {
ym('reachGoal', 'ac_family_edit');
reachGoal('ac_family_edit');
setForm({
name: user.name,
gender: user.gender,
@@ -116,22 +117,32 @@ export function PersonModal({
{showExpanded ? (
<Content onClick={event => event.stopPropagation()}>
<Header>
<h2>{showExpanded == 'favorite' ? 'Любимые' : 'Не добавлять'}</h2>
<h2>{showExpanded == 'favorite' ? 'Любимые' : 'Исключить'}</h2>
<CloseButton type="button" onClick={() => setShowExpanded('')}>
×
</CloseButton>
</Header>
<FormGroup>
<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) || []).map(tag => (*/}
{availableTags.map(tag => (
<Tag
key={tag}
$type={showExpanded}
$active={(showExpanded == 'favorite' ? form.favorite : form.avoided).indexOf(tag) > -1}
onClick={() => {
if ((showExpanded == 'favorite' ? form.favorite : form.avoided).indexOf(tag) > -1) {
(showExpanded == 'favorite' ? handleFavoriteChange : handleAvoidChange)(
((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).filter(item => item != tag)
)}>
×
</button>
)
} else {
(showExpanded == 'favorite' ? handleFavoriteChange : handleAvoidChange)(
((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).concat(tag)
)
}
}}
>
{tag}
</Tag>
))}
</TagsWrap>
@@ -208,7 +219,7 @@ export function PersonModal({
<PrefsGrid>
<TagsInput
title="Не добавлять"
title="Исключить"
type="avoid"
value={form.avoided}
availableTags={availableTags}
@@ -238,6 +249,7 @@ export function PersonModal({
}
const Overlay = styled.div`
height: 100vh;
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
@@ -354,6 +366,7 @@ const GenderButton = styled.button<{ $active: boolean }>`
cursor: pointer;
transition: 0.1s;
font-weight: 370;
height: 44px;
&:hover {
outline: 1px solid #5EA12D;
@@ -363,6 +376,7 @@ const GenderButton = styled.button<{ $active: boolean }>`
const PrefsGrid = styled.div`
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 20px;
border-top: 1px solid #f0f0f0;
+105 -151
View File
@@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import styled from 'styled-components';
import {declension} from "../../utils/declension";
interface TagsInputProps {
title: string;
@@ -13,163 +13,140 @@ interface TagsInputProps {
export function TagsInput({
title,
type,
value,
availableTags,
excludedTags,
onChange,
onExpand,
}: TagsInputProps) {
const [query, setQuery] = useState('');
const matches = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return [];
const selected = new Set([...value, ...excludedTags]);
return availableTags.filter(tag =>
tag.toLowerCase().includes(normalizedQuery) && !selected.has(tag),
);
}, [availableTags, excludedTags, query, value]);
function addTag(tag: string) {
if (value.includes(tag)) return;
onChange([...value, tag]);
setQuery('');
}
function removeTag(tag: string) {
onChange(value.filter(item => item !== tag));
}
return (
<Column>
<Title>{title}</Title>
<Input
type="text"
placeholder={type === 'avoid' ? 'Например: лук' : 'Например: яблоко'}
value={query}
onChange={event => setQuery(event.target.value)}
/>
{matches.length > 0 && (
<Autocomplete>
{matches.map(match => (
<button
key={match}
type="button"
onClick={() => addTag(match)}
>
{match}
</button>
))}
</Autocomplete>
)}
<TagsWrap>
{value.slice(0, 4).map(tag => (
<Tag key={tag} $type={type}>
{tag}
<button type="button" onClick={() => removeTag(tag)}>
×
</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>
<Row>
<Column>
<Title>{title}</Title>
<Info>{value.length > 0 ? declension(value.length, ['Выбрана', 'Выбраны', 'Выбраны']) + ': ' + value.length : 'Не выбраны'}</Info>
</Column>
<AddWrapper>
<Button
type="button"
onClick={onExpand}
>
Выбрать
</Button>
</AddWrapper>
</Row>
);
}
const Column = styled.div`
flex: 1;
position: relative;
const AddWrapper = styled.div`
width: 100%;
display: flex;
align-items: center;
justify-content: flex-end;
`;
const Button = styled.button`
padding: 18px 23px;
border-radius: 12px;
min-width: 160px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
color: ${({theme}) => theme.colors.green};
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: 0.2s;
background-color: transparent;
border: 0;
span {
color: inherit;
font-weight: inherit;
}
&:hover {
background-color: #F7FBF6;
}
&:active,
&:focus {
background-color: #EEF8EB;
outline: none;
}
span {
font-size: 20px;
margin-left: 8px;
font-weight: 300;
}
`;
const Row = styled.div`
display: flex;
flex-direction: row;
width: 100%;
flex: 1 1 100%;
justify-content: center;
`
const Column = styled.div`
flex: 1 1 100%;
position: relative;
display: flex;
flex-direction: column;
justify-content: center;
white-space: nowrap;
`;
const Info = styled.span`
color: ${({theme}) => theme.colors.gray};
`;
const Title = styled.span`
display: block;
font-size: 16px;
font-weight: 370;
color: ${({ theme }) => theme.colors.darkGreen};
color: ${({theme}) => theme.colors.darkGreen};
margin-bottom: 8px;
`;
const Input = styled.input`
width: 100%;
padding: 8px 10px;
border: 1px solid #e1e1e1;
border-radius: 8px;
font-size: 14px;
outline: none;
margin-bottom: 6px;
height: 44px;
&::placeholder {
color:#A3ADA2;
}
&:hover {
border-color: ${({theme}) => theme.colors.gray};
}
&:focus {
border-color: ${({ theme }) => theme.colors.green};
}
`;
const Autocomplete = styled.div`
position: absolute;
top: 65px;
left: 0;
width: 100%;
background: #fff;
border: 1px solid #e1e1e1;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
max-height: 120px;
overflow-y: auto;
z-index: 20;
button {
width: 100%;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
transition: 0.2s;
background: transparent;
border: 0;
text-align: left;
&:hover {
background: #f3f4f6;
}
}
`;
export const TagsWrap = styled.div`
display: flex;
flex-wrap: wrap;
gap: 6px;
`;
export const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>`
const palette = {
inactive: {
avoid: {
bg: '#FDE3E3',
text: '#0c3b2e',
},
favorite: {
bg: '#F1F9D7',
text: '#0c3b2e',
}
},
active: {
avoid: {
bg: '#9F2518',
text: '#FDE3E3',
},
favorite: {
bg: '#5EA12D',
text: '#F1F9D7',
}
},
};
export const Tag = styled.div<{ $type: 'avoid' | 'favorite', $active: boolean }>`
cursor: pointer;
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 6px;
font-size: 13px;
background-color: ${({ $type }) => ($type === 'avoid' ? '#fce4ec' : '#f0f4c3')};
color: ${({ $type }) => ($type === 'avoid' ? '#c62828' : '#33691e')};
background-color: ${({$type,$active}) => palette[$active ? 'active' : 'inactive'][$type].bg};
color: ${({$type,$active}) => palette[$active ? 'active' : 'inactive'][$type].text};
button {
margin-left: 6px;
cursor: pointer;
font-size: 15px;
opacity: 0.6;
@@ -182,26 +159,3 @@ export const Tag = styled.div<{ $type: 'avoid' | 'favorite' }>`
}
}
`;
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')};
}
`
+1 -1
View File
@@ -91,7 +91,7 @@ const LabelsContainer = styled.div`
const Label = styled.span`
position: absolute;
font-size: 16px;
color: #333333;
color: #0C3B2E;
@media (max-width: 768px) {
font-size: 14px;
+70 -70
View File
@@ -1,5 +1,5 @@
import React from 'react';
import styled, { css } from 'styled-components';
import styled, {css} from 'styled-components';
export type InputState = 'default' | 'success' | 'error' | 'disabled';
@@ -23,91 +23,91 @@ const Container = styled.div`
`;
const Label = styled.label<{ $state: InputState }>`
font-size: 16px;
color: #000;
margin-bottom: 4px;
${({ $state }) => $state === 'disabled' && css`
color: #9e9e9e;
`}
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;
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;
/* Default State */
border: 1px solid #e0e0e0;
`}
/* Padding adjustments for clear button */
${({ $allowClear }) => $allowClear && css`
input {
padding-right: 40px;
&: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: #A3ADA2;
}
width: 100%;
padding: 12px 16px;
border: none;
outline: none;
background: transparent;
font-size: 16px;
color: #0C3B2E;
${({ $state }) => $state === 'disabled' && css`
color: #9e9e9e;
cursor: not-allowed;
`}
&::placeholder {
color: #A3ADA2;
}
&:hover {
border-color: #72b541;
}
&:focus {
border-color: #72b541;
}
${({$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;
position: absolute;
right: 12px;
background: none;
border: none;
font-size: 20px;
color: #9e9e9e;
cursor: pointer;
padding: 0;
line-height: 1;
&:hover {
color: #333;
}
&:hover {
color: #0C3B2E;
}
${({ $state }) => $state === 'disabled' && css`
cursor: not-allowed;
color: #c0c0c0;
`}
${({$state}) => $state === 'disabled' && css`
cursor: not-allowed;
color: #c0c0c0;
`}
`;
export const TextInput: React.FC<TextInputProps> = ({
+82
View File
@@ -0,0 +1,82 @@
import React, { useState } from 'react';
import styled from 'styled-components';
// Стилизованные компоненты
const Container = styled.div`
position: relative;
width: 100%;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
`;
const StyledTextarea = styled.textarea`
width: 100%;
min-height: 224px;
/* Добавляем нижний отступ, чтобы текст не заезжал под счетчик */
padding: 16px 16px 40px 16px;
border: 1px solid #e0e0e0;
border-radius: 8px;
font-size: 16px;
color: #0C3B2E;
background: white;
resize: vertical; /* Разрешаем растягивать по высоте */
outline: none;
transition: border-color 0.2s ease-in-out;
box-sizing: border-box;
&::placeholder {
color: #a0a0a0;
}
&:hover {
border-color: #a0a0a0;
}
&:focus {
border-color: #72b541;
}
`;
const Counter = styled.div`
position: absolute;
bottom: -20px;
right: 0;
font-size: 14px;
color: #9ca3af;
user-select: none;
pointer-events: none;
`;
// Интерфейс пропсов
interface FeedbackTextareaProps {
placeholder?: string;
maxLength?: number;
}
interface FeedbackTextareaProps {
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
placeholder?: string;
maxLength?: number;
}
const FeedbackTextarea: React.FC<FeedbackTextareaProps> = ({
value,
onChange,
placeholder = 'Напишите, что бы вы хотели поменять',
maxLength = 250,
}) => {
return (
<Container>
<StyledTextarea
value={value}
onChange={onChange}
placeholder={placeholder}
maxLength={maxLength}
/>
<Counter>
{value.length}/{maxLength}
</Counter>
</Container>
);
};
export default FeedbackTextarea;