я.метрика

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

BIN
images/broc-fail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

View File

@ -15,7 +15,8 @@ import CustomSlider from "./components/Fullness/FullnessSlider";
import {useGlobalZustandState} from "./hooks/useGlobalZustandState";
import {setUsers, useAppDispatch, useAppSelector} from "./store";
import {FinitaLaCommedia} from "./components/Cart/FinitaLaCommedia";
import ym from "react-yandex-metrika";
import {reachGoal} from "./hooks/useYM";
export function App() {
const [initialData, setInitialData] = useState<InitialData | null>(null);
@ -23,7 +24,7 @@ export function App() {
const [isCartComplete, setIsCartComplete] = useState(false);
const [showEmptyPopup, setShowEmptyPopup] = useState(false);
const [isCartLoading, setIsCartLoading] = useState(true);
const [percent, setPercent] = useState(90);
const [showLoader, setShowLoader] = useState(false);
const [openSection, setOpenSection] = useState<string>('family');
const [needRefresh, setNeedRefresh] = useState<boolean>(true);
const [isFullnessHighlighted, setIsFullnessHighlighted] = useState<boolean>(false);
@ -35,13 +36,14 @@ export function App() {
const loadInitialState = async () => {
setShowLoader(true);
loadInitialData().then(data => {
setInitialData(data);
// setPercent(data.percent);
dispatch(setUsers(data.users));
setProducts(data.cart.flat().map(product => normalizeProductImage(product)));
setInitial(false, data.excess || 0, data.precision || 0);
setIsCartComplete(false)
setShowLoader(false);
});
};
@ -62,22 +64,26 @@ export function App() {
const days = initialData?.days ?? 7;
const handleDaysChange = useCallback(async (nextDays: number) => {
setShowLoader(true);
await setOption('days', nextDays);
loadInitialState();
}, [loadInitialState]);
const handleUsersChange = useCallback(async (nextUsers: User[]) => {
setShowLoader(true);
await setOption('users', nextUsers);
loadInitialState();
}, [loadInitialState]);
const handleCategoriesChange = useCallback((nextCategories: string[]) => {
setInitialData(current => current ? { ...current, initial_groups: nextCategories } : current);
const handleCategoriesChange = useCallback(async (nextCategories: string[]) => {
setShowLoader(true);
await setOption('initial_groups', nextCategories);
loadInitialState();
}, [loadInitialState]);
const handlePercentChange = useCallback((percent: number) => {
setInitialData(current => current ? { ...current, percent } : current);
const handlePercentChange = useCallback(async (fullness: number) => {
setShowLoader(true);
await setOption('fullness', fullness);
loadInitialState();
}, [loadInitialState]);
@ -90,11 +96,8 @@ export function App() {
}, [isCartComplete, isCartLoading, precision, setShowEmptyPopup]);
useEffect(() => {
ym('reachGoal', 'ac_open_settings_tab_' + openSection);
reachGoal('ac_open_settings_tab_' + openSection);
}, [openSection])
useEffect(() => {
ym('reachGoal', 'ac_fill_percent_' + percent);
}, [percent])
const pageContent = useMemo(() => {
if (!initialData) {
@ -103,9 +106,10 @@ export function App() {
return (
<Container $fullHeight={mobileShowFilters}>
{showLoader && <Overlay><div /></Overlay>}
<LeftColumn $dataShow={!mobileShowFilters}>
<TitleBlock>
<h1>Хватило</h1>
<h1>Кладило</h1>
<MobileFilter onClick={() => setMobileShowFilters(true)}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 3V7M19 21V11M12 3V15M12 21V19M5 3V5M5 21V9" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round"/>
@ -140,6 +144,7 @@ export function App() {
setIsCartLoading(false);
}}
onLoadingChange={setIsCartLoading}
onMistake={() => setNeedRefresh(true)}
/>
</LeftColumn>
@ -209,7 +214,7 @@ export function App() {
icon={<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M17 13.23C17 13.23 16.09 12.77 15.182 12.77C13.818 12.77 12 14.615 12 17.385C12 20.154 14.49 22 17 22C19.51 22 22 20.154 22 17.385C22 14.616 20.182 12.769 18.818 12.769C17.909 12.769 17 13.231 17 13.231C17 11.847 17.91 10.001 19.727 10.001M10.655 5C11.551 5 12.278 4.328 12.278 3.5C12.278 2.672 11.55 2 10.655 2H5.245C4.349 2 3.623 2.672 3.623 3.5C3.623 4.328 4.349 5 5.246 5M11.169 4.923C12.125 6.689 12.909 8.283 13.389 10C13.4283 10.14 13.4653 10.281 13.5 10.423M10.428 22H6.328C2.747 22 2 21.31 2 18V13.777C2 10.377 3.098 7.886 4.705 4.915" stroke="#859D96" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>}
description={openSection === 'fullness' ? 'Чем меньше значение, тем меньше продуктов и калорий в корзине' : percent + '% — ваша цель. Результат может варьироваться'}
description={openSection === 'fullness' ? 'Чем меньше значение, тем меньше продуктов и калорий в корзине' : initialData.fullness + '% — ваша цель. Результат может варьироваться'}
isHighlighted={isFullnessHighlighted}
isOpen={openSection === 'fullness'}
onToggle={() => {
@ -219,7 +224,7 @@ export function App() {
setOpenSection(openSection === 'fullness' ? '' : 'fullness')
}}
>
<CustomSlider defaultValue={percent} onChange={setPercent} />
<CustomSlider defaultValue={initialData.fullness} onChange={handlePercentChange} />
<Alert>
<div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
@ -262,6 +267,40 @@ export function App() {
);
}
const Overlay = styled.div`
position: fixed;
display: flex;
inset: 0;
background-color: rgba(255, 255, 255, 0.1);
justify-content: center;
align-items: center;
z-index: 9;
overflow: auto;
height: 100vh;
div {
--color-1: #ffa0a0;
--size: 1.2px;
width: calc(48 * var(--size));
height: calc(48 * var(--size));
border: calc(5 * var(--size)) solid var(--color-1);
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`;
const LoadingPage = styled.div`
min-height: 100vh;
display: flex;

View File

@ -124,11 +124,20 @@ export function subscribeOnFinish(email: string): Promise<unknown> {
});
}
export function subscribeOnFail(email: string, comment: string): Promise<unknown> {
return requestWithRefresh(apiUrl('/api/promo/subscribe_fail'), {
method: 'POST',
headers: jsonHeaders,
body: JSON.stringify({ email, comment }),
});
}
const fallbackInitialData: InitialData = {
cart: [],
users: [],
initial_groups: [],
available_groups: [],
fullness: 90,
days: 7,
excess: 0,
precision: 0,

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)}} />}
</>
);
}
@ -340,3 +352,12 @@ const ShowMore = styled.button`
margin-left: 6px;
}
`;
const NotEnoughWrapper = styled.div`
display: flex;
flex: 1 1 100%;
justify-content: center;
`;
const ShowNotEnough = styled(ShowMore)`
text-align: center;
`;

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} />

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;

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;

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;
}
`;

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;

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;

View File

@ -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);

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;

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,88 +13,92 @@ 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 (
<Row>
<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>
<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;
@ -103,73 +107,46 @@ const Title = styled.span`
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')};
}
`

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;

View File

@ -42,6 +42,7 @@ const Wrapper = styled.div<{ $state: InputState; $allowClear: boolean }>`
/* Default State */
border: 1px solid #e0e0e0;
&:focus-within {
border-color: #b3b3b3;
}
@ -49,21 +50,12 @@ const Wrapper = styled.div<{ $state: InputState; $allowClear: boolean }>`
/* Success State */
${({$state}) => $state === 'success' && css`
border: 2px solid #4CAF50;
`}
/* Error State */
${({ $state }) => $state === 'error' && css`
`} /* Error State */ ${({$state}) => $state === 'error' && css`
border: 2px solid #F44336;
`}
/* Disabled State */
${({ $state }) => $state === 'disabled' && css`
`} /* Disabled State */ ${({$state}) => $state === 'disabled' && css`
background-color: #f2f2f2;
border: 1px solid #e0e0e0;
`}
/* Padding adjustments for clear button */
${({ $allowClear }) => $allowClear && css`
`} /* Padding adjustments for clear button */ ${({$allowClear}) => $allowClear && css`
input {
padding-right: 40px;
}
@ -77,12 +69,20 @@ const Input = styled.input<{ $state: InputState }>`
outline: none;
background: transparent;
font-size: 16px;
color: #333;
color: #0C3B2E;
&::placeholder {
color: #A3ADA2;
}
&:hover {
border-color: #72b541;
}
&:focus {
border-color: #72b541;
}
${({$state}) => $state === 'disabled' && css`
color: #9e9e9e;
cursor: not-allowed;
@ -101,7 +101,7 @@ const ClearButton = styled.button<{ $state: InputState }>`
line-height: 1;
&:hover {
color: #333;
color: #0C3B2E;
}
${({$state}) => $state === 'disabled' && css`

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;

View File

@ -3,12 +3,14 @@ import { getCartUpdates } from '../api/promoApi';
import { normalizeProductImage } from '../utils/product';
import type { Product } from '../types/cart';
import {useGlobalZustandState} from "./useGlobalZustandState";
import ym from "react-yandex-metrika";
import {reachGoal} from "./useYM";
interface UseCartPollingParams {
enabled: boolean;
onProductsChange: Dispatch<SetStateAction<Product[]>>;
onComplete: () => void;
onMistake: () => void;
onLoadingChange: (isLoading: boolean) => void;
setDisplayText: Dispatch<SetStateAction<string>>;
}
@ -115,6 +117,7 @@ export function useCartPolling({
onComplete,
onLoadingChange,
setDisplayText,
onMistake,
}: UseCartPollingParams) {
const timeoutRef = useRef<number | null>(null);
const {updateFromResponse, markAsComplete, markAsIncomplete} = useGlobalZustandState((state) => state);
@ -161,7 +164,7 @@ export function useCartPolling({
missing_scenario_element = -1;
if (update.status === 'complete') {
ym('reachGoal', 'ac_cart_complete');
reachGoal('ac_cart_complete');
onLoadingChange(false);
onComplete();
updateFromResponse(true, update.payload?.excess || 0, update.payload?.precision || 0);
@ -195,6 +198,10 @@ export function useCartPolling({
}
});
if (update.payload?.unique_sku_cnt && update.payload?.unique_sku_cnt !== next.length) {
onMistake();
}
return next;
});

9
src/hooks/useYM.ts Normal file
View File

@ -0,0 +1,9 @@
export const reachGoal = (goalName: string) => {
// @ts-ignore
if (typeof window.yaCounter68914120 !== undefined) {
// @ts-ignore
return window.yaCounter68914120.reachGoal(goalName);
}
return null;
};

View File

@ -16,16 +16,10 @@ if (!root) {
createRoot(root).render(
<React.StrictMode>
<Provider store={store}>
<ThemeProvider theme={theme}>
<GlobalStyle />
<App />
<YMInitializer
accounts={[68914120]}
options={{ clickmap: true, trackLinks: true, accurateTrackBounce: true, webvisor: true }}
/>
</ThemeProvider>
</Provider>
</React.StrictMode>,
</Provider>,
);

View File

@ -66,4 +66,9 @@ export const GlobalStyle = createGlobalStyle`
transform: rotate(360deg);
}
}
*::placeholder {
font-weight: 300;
font-size: 16px;
}
`;

View File

@ -30,5 +30,16 @@
<body>
<div id="root"></div>
<script nonce="**MY_PRETTY_CSP_NONCE**" type="module" src="/src/main.tsx"></script>
<script nonce="**MY_PRETTY_CSP_NONCE**" type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,'script','https://mc.yandex.ru/metrika/tag.js', 'ym');
ym(68914120, 'init', {webvisor:true, clickmap:true, referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/68914120" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
</body>
</html>

View File

@ -6,6 +6,7 @@ export interface InitialData {
available_groups: string[];
initial_groups: string[];
days: number;
fullness: number;
users: User[];
cart: Product[][];
excess?: number;
@ -21,5 +22,6 @@ export interface CartUpdateResponse {
excess?: number;
precision?: number;
chunk?: Product[];
unique_sku_cnt?: number;
};
}