я.метрика

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 {useGlobalZustandState} from "./hooks/useGlobalZustandState";
import {setUsers, useAppDispatch, useAppSelector} from "./store"; import {setUsers, useAppDispatch, useAppSelector} from "./store";
import {FinitaLaCommedia} from "./components/Cart/FinitaLaCommedia"; import {FinitaLaCommedia} from "./components/Cart/FinitaLaCommedia";
import ym from "react-yandex-metrika";
import {reachGoal} from "./hooks/useYM";
export function App() { export function App() {
const [initialData, setInitialData] = useState<InitialData | null>(null); const [initialData, setInitialData] = useState<InitialData | null>(null);
@ -23,7 +24,7 @@ export function App() {
const [isCartComplete, setIsCartComplete] = useState(false); const [isCartComplete, setIsCartComplete] = useState(false);
const [showEmptyPopup, setShowEmptyPopup] = useState(false); const [showEmptyPopup, setShowEmptyPopup] = useState(false);
const [isCartLoading, setIsCartLoading] = useState(true); const [isCartLoading, setIsCartLoading] = useState(true);
const [percent, setPercent] = useState(90); const [showLoader, setShowLoader] = useState(false);
const [openSection, setOpenSection] = useState<string>('family'); const [openSection, setOpenSection] = useState<string>('family');
const [needRefresh, setNeedRefresh] = useState<boolean>(true); const [needRefresh, setNeedRefresh] = useState<boolean>(true);
const [isFullnessHighlighted, setIsFullnessHighlighted] = useState<boolean>(false); const [isFullnessHighlighted, setIsFullnessHighlighted] = useState<boolean>(false);
@ -35,13 +36,14 @@ export function App() {
const loadInitialState = async () => { const loadInitialState = async () => {
setShowLoader(true);
loadInitialData().then(data => { loadInitialData().then(data => {
setInitialData(data); setInitialData(data);
// setPercent(data.percent);
dispatch(setUsers(data.users)); dispatch(setUsers(data.users));
setProducts(data.cart.flat().map(product => normalizeProductImage(product))); setProducts(data.cart.flat().map(product => normalizeProductImage(product)));
setInitial(false, data.excess || 0, data.precision || 0); setInitial(false, data.excess || 0, data.precision || 0);
setIsCartComplete(false) setIsCartComplete(false)
setShowLoader(false);
}); });
}; };
@ -62,22 +64,26 @@ export function App() {
const days = initialData?.days ?? 7; const days = initialData?.days ?? 7;
const handleDaysChange = useCallback(async (nextDays: number) => { const handleDaysChange = useCallback(async (nextDays: number) => {
setShowLoader(true);
await setOption('days', nextDays); await setOption('days', nextDays);
loadInitialState(); loadInitialState();
}, [loadInitialState]); }, [loadInitialState]);
const handleUsersChange = useCallback(async (nextUsers: User[]) => { const handleUsersChange = useCallback(async (nextUsers: User[]) => {
setShowLoader(true);
await setOption('users', nextUsers); await setOption('users', nextUsers);
loadInitialState(); loadInitialState();
}, [loadInitialState]); }, [loadInitialState]);
const handleCategoriesChange = useCallback((nextCategories: string[]) => { const handleCategoriesChange = useCallback(async (nextCategories: string[]) => {
setInitialData(current => current ? { ...current, initial_groups: nextCategories } : current); setShowLoader(true);
await setOption('initial_groups', nextCategories);
loadInitialState(); loadInitialState();
}, [loadInitialState]); }, [loadInitialState]);
const handlePercentChange = useCallback((percent: number) => { const handlePercentChange = useCallback(async (fullness: number) => {
setInitialData(current => current ? { ...current, percent } : current); setShowLoader(true);
await setOption('fullness', fullness);
loadInitialState(); loadInitialState();
}, [loadInitialState]); }, [loadInitialState]);
@ -90,11 +96,8 @@ export function App() {
}, [isCartComplete, isCartLoading, precision, setShowEmptyPopup]); }, [isCartComplete, isCartLoading, precision, setShowEmptyPopup]);
useEffect(() => { useEffect(() => {
ym('reachGoal', 'ac_open_settings_tab_' + openSection); reachGoal('ac_open_settings_tab_' + openSection);
}, [openSection]) }, [openSection])
useEffect(() => {
ym('reachGoal', 'ac_fill_percent_' + percent);
}, [percent])
const pageContent = useMemo(() => { const pageContent = useMemo(() => {
if (!initialData) { if (!initialData) {
@ -103,9 +106,10 @@ export function App() {
return ( return (
<Container $fullHeight={mobileShowFilters}> <Container $fullHeight={mobileShowFilters}>
{showLoader && <Overlay><div /></Overlay>}
<LeftColumn $dataShow={!mobileShowFilters}> <LeftColumn $dataShow={!mobileShowFilters}>
<TitleBlock> <TitleBlock>
<h1>Хватило</h1> <h1>Кладило</h1>
<MobileFilter onClick={() => setMobileShowFilters(true)}> <MobileFilter onClick={() => setMobileShowFilters(true)}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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"/> <path d="M19 3V7M19 21V11M12 3V15M12 21V19M5 3V5M5 21V9" stroke="#5EA12D" strokeWidth="2" strokeLinecap="round"/>
@ -140,6 +144,7 @@ export function App() {
setIsCartLoading(false); setIsCartLoading(false);
}} }}
onLoadingChange={setIsCartLoading} onLoadingChange={setIsCartLoading}
onMistake={() => setNeedRefresh(true)}
/> />
</LeftColumn> </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"> 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"/> <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>} </svg>}
description={openSection === 'fullness' ? 'Чем меньше значение, тем меньше продуктов и калорий в корзине' : percent + '% — ваша цель. Результат может варьироваться'} description={openSection === 'fullness' ? 'Чем меньше значение, тем меньше продуктов и калорий в корзине' : initialData.fullness + '% — ваша цель. Результат может варьироваться'}
isHighlighted={isFullnessHighlighted} isHighlighted={isFullnessHighlighted}
isOpen={openSection === 'fullness'} isOpen={openSection === 'fullness'}
onToggle={() => { onToggle={() => {
@ -219,7 +224,7 @@ export function App() {
setOpenSection(openSection === 'fullness' ? '' : 'fullness') setOpenSection(openSection === 'fullness' ? '' : 'fullness')
}} }}
> >
<CustomSlider defaultValue={percent} onChange={setPercent} /> <CustomSlider defaultValue={initialData.fullness} onChange={handlePercentChange} />
<Alert> <Alert>
<div> <div>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <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` const LoadingPage = styled.div`
min-height: 100vh; min-height: 100vh;
display: flex; 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 = { const fallbackInitialData: InitialData = {
cart: [], cart: [],
users: [], users: [],
initial_groups: [], initial_groups: [],
available_groups: [], available_groups: [],
fullness: 90,
days: 7, days: 7,
excess: 0, excess: 0,
precision: 0, precision: 0,

View File

@ -9,7 +9,8 @@ import { CartItem } from './CartItem';
import { CartFooter } from './CartFooter'; import { CartFooter } from './CartFooter';
import { ProductModal } from './ProductModal'; import { ProductModal } from './ProductModal';
import {useGlobalZustandState} from "../../hooks/useGlobalZustandState"; import {useGlobalZustandState} from "../../hooks/useGlobalZustandState";
import ym from "react-yandex-metrika"; import {NotEnoughPopup} from "./NotEnoughPopup";
import {reachGoal} from "../../hooks/useYM";
interface CartProps { interface CartProps {
products: Product[]; products: Product[];
@ -18,6 +19,7 @@ interface CartProps {
isLoading: boolean; isLoading: boolean;
onProductsChange: Dispatch<SetStateAction<Product[]>>; onProductsChange: Dispatch<SetStateAction<Product[]>>;
onComplete: () => void; onComplete: () => void;
onMistake: () => void;
onLoadingChange: (isLoading: boolean) => void; onLoadingChange: (isLoading: boolean) => void;
} }
@ -29,7 +31,9 @@ export function Cart({
onProductsChange, onProductsChange,
onComplete, onComplete,
onLoadingChange, onLoadingChange,
onMistake,
}: CartProps) { }: CartProps) {
const [showNotEnoughPopup, setShowNotEnoughPopup] = useState(false);
const [visibleCount, setVisibleCount] = useState(5); const [visibleCount, setVisibleCount] = useState(5);
const [displayText, setDisplayText] = useState(''); const [displayText, setDisplayText] = useState('');
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null); const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
@ -54,10 +58,11 @@ export function Cart({
onComplete, onComplete,
onLoadingChange, onLoadingChange,
setDisplayText, setDisplayText,
onMistake,
}); });
async function handleReplace(product: Product) { async function handleReplace(product: Product) {
ym('reachGoal', 'ac_product_replace_' + product.name); reachGoal('ac_product_replace_' + product.name);
setProcessingProductName(product.name); setProcessingProductName(product.name);
try { try {
@ -69,7 +74,7 @@ export function Cart({
} }
async function handleDelete(product: Product) { async function handleDelete(product: Product) {
ym('reachGoal', 'ac_product_delete_' + product.name); reachGoal('ac_product_delete_' + product.name);
setProcessingProductName(product.name); setProcessingProductName(product.name);
try { try {
@ -85,7 +90,7 @@ export function Cart({
<Summary> <Summary>
<div> <div>
<SummaryTitle> <SummaryTitle>
Ваша корзина на <Green>{days} {declension(days, ['день', 'дня', 'дней'])}</Green> Ваша закупка на <Green>{days} {declension(days, ['день', 'дня', 'дней'])}</Green>
{showFullRefreshButton && <AbsoluteRefreshButton>Начать заново</AbsoluteRefreshButton>} {showFullRefreshButton && <AbsoluteRefreshButton>Начать заново</AbsoluteRefreshButton>}
</SummaryTitle> </SummaryTitle>
@ -122,6 +127,11 @@ export function Cart({
onDelete={() => handleDelete(product)} onDelete={() => handleDelete(product)}
/> />
))} ))}
{products.length === visibleProducts.length && isComplete && !isLoading && <NotEnoughWrapper>
<ShowNotEnough type="button" onClick={() => setShowNotEnoughPopup(true)}>
Не хватило
</ShowNotEnough>
</NotEnoughWrapper>}
</CartList> </CartList>
{products.length === 0 && ( {products.length === 0 && (
@ -155,6 +165,8 @@ export function Cart({
setSelectedProduct(null); setSelectedProduct(null);
}} }}
/> />
{showNotEnoughPopup && <NotEnoughPopup onClose={() => {setShowNotEnoughPopup(false)}} />}
</> </>
); );
} }
@ -340,3 +352,12 @@ const ShowMore = styled.button`
margin-left: 6px; 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 {useState} from "react";
import {OrderPopup} from "./OrderPopup"; import {OrderPopup} from "./OrderPopup";
import {useAppSelector} from "../../store"; import {useAppSelector} from "../../store";
import ym from "react-yandex-metrika";
import {reachGoal} from "../../hooks/useYM";
interface CartFooterProps { interface CartFooterProps {
@ -31,10 +32,10 @@ export function CartFooter({ isVisible, totalPrice }: CartFooterProps) {
</TotalWrap> </TotalWrap>
<CheckoutButton type="button" onClick={() => { <CheckoutButton type="button" onClick={() => {
ym('reachGoal', 'ac_hvatilo') reachGoal('ac_hvatilo')
setShowCompletePopup(true) setShowCompletePopup(true)
}}> }}>
{users.length > 1 ? 'Нам' : 'Мне'} хватило Хватило
</CheckoutButton> </CheckoutButton>
{showCompletePopup && <> {showCompletePopup && <>
<OrderPopup setShowCompletePopup={setShowCompletePopup} /> <OrderPopup setShowCompletePopup={setShowCompletePopup} />

View File

@ -2,7 +2,7 @@ import {useEffect, useState} from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import {getProductAmount} from '../../utils/product'; import {getProductAmount} from '../../utils/product';
import type {Product} from '../../types/cart'; import type {Product} from '../../types/cart';
import ym from "react-yandex-metrika"; import {reachGoal} from "../../hooks/useYM";
interface CartItemProps { interface CartItemProps {
product: Product; product: Product;
@ -21,8 +21,10 @@ export function CartItem({
}: CartItemProps) { }: CartItemProps) {
const [isMenuOpen, setIsMenuOpen] = useState(false); const [isMenuOpen, setIsMenuOpen] = useState(false);
useEffect(() => { useEffect(() => {
ym('reachGoal', 'ac_product_replacement_menu_' + product.name); if (isMenuOpen) {
}, [product.name]) reachGoal('ac_product_replacement_menu_' + product.name);
}
}, [product.name, isMenuOpen])
return ( return (
<Item $processing={isProcessing}> <Item $processing={isProcessing}>
@ -148,6 +150,7 @@ const MenuButton = styled.button`
`; `;
const Overlay = styled.div` const Overlay = styled.div`
height: 100vh;
position: fixed; position: fixed;
display: flex; display: flex;
inset: 0; inset: 0;

View File

@ -32,6 +32,7 @@ const Overlay = styled.div`
align-items: center; align-items: center;
z-index: 4; z-index: 4;
overflow: auto; overflow: auto;
height: 100vh;
`; `;
const Window = styled.div` const Window = styled.div`
background-color: #ffffff; 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 styled, {css} from "styled-components";
import {useState} from "react"; import {useState} from "react";
import {FinishPopupContent} from "./FinishPopupContent"; import {FinishPopupContent} from "./FinishPopupContent";
import ym from "react-yandex-metrika"; import {reachGoal} from "../../hooks/useYM";
export const OrderPopup = ({setShowCompletePopup}: any) => { export const OrderPopup = ({setShowCompletePopup}: any) => {
const [showFinal, setShowFinal] = useState(false); const [showFinal, setShowFinal] = useState(false);
@ -18,11 +19,11 @@ export const OrderPopup = ({setShowCompletePopup}: any) => {
<ServiceFeeDescription>Включена в итоговую стоимость заказа</ServiceFeeDescription> <ServiceFeeDescription>Включена в итоговую стоимость заказа</ServiceFeeDescription>
<ButtonBlock> <ButtonBlock>
<ActiveButton onClick={() => { <ActiveButton onClick={() => {
ym('reachGoal', 'ac_order_button') reachGoal('ac_order_button')
setShowFinal(true) setShowFinal(true)
}}>Продолжить</ActiveButton> }}>Продолжить</ActiveButton>
<Button onClick={() => { <Button onClick={() => {
ym('reachGoal', 'ac_no_order_button') reachGoal('ac_no_order_button')
setShowCompletePopup(false) setShowCompletePopup(false)
}}>Вернуться к корзине</Button> }}>Вернуться к корзине</Button>
</ButtonBlock> </ButtonBlock>
@ -32,6 +33,7 @@ export const OrderPopup = ({setShowCompletePopup}: any) => {
} }
const Overlay = styled.div` const Overlay = styled.div`
height: 100vh;
position: fixed; position: fixed;
display: flex; display: flex;
inset: 0; inset: 0;

View File

@ -3,7 +3,8 @@ import { formatCurrency } from '../../utils/formatCurrency';
import { getLargeProductImage } from '../../utils/product'; import { getLargeProductImage } from '../../utils/product';
import type { Product } from '../../types/cart'; import type { Product } from '../../types/cart';
import {useEffect} from "react"; import {useEffect} from "react";
import ym from "react-yandex-metrika";
import {reachGoal} from "../../hooks/useYM";
interface ProductModalProps { interface ProductModalProps {
product: Product | null; product: Product | null;
@ -20,7 +21,7 @@ export function ProductModal({
}: ProductModalProps) { }: ProductModalProps) {
useEffect(() => { useEffect(() => {
if (product !== null) { if (product !== null) {
ym('reachGoal', 'ac_open_popup') reachGoal('ac_open_popup')
} }
}, [product]); }, [product]);
@ -62,6 +63,7 @@ export function ProductModal({
} }
const Overlay = styled.div` const Overlay = styled.div`
height: 100vh;
position: fixed; position: fixed;
display: flex; display: flex;
inset: 0; inset: 0;

View File

@ -2,7 +2,8 @@ import { useMemo, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { setOption } from '../../api/promoApi'; import { setOption } from '../../api/promoApi';
import { useDebounceBatch } from '../../hooks/useDebounceBatch'; import { useDebounceBatch } from '../../hooks/useDebounceBatch';
import ym from "react-yandex-metrika"; import {reachGoal} from "../../hooks/useYM";
interface CategoriesSectionProps { interface CategoriesSectionProps {
categories: string[]; categories: string[];
@ -32,7 +33,7 @@ export function CategoriesSection({
const sendUpdatedCategories = useDebounceBatch<string>(async categoryNames => { const sendUpdatedCategories = useDebounceBatch<string>(async categoryNames => {
let nextCategories = [...selectedCategories]; let nextCategories = [...selectedCategories];
ym('reachGoal', 'ac_set_categories'); reachGoal('ac_set_categories');
categoryNames.forEach(categoryName => { categoryNames.forEach(categoryName => {
if (nextCategories.includes(categoryName)) { if (nextCategories.includes(categoryName)) {
nextCategories = nextCategories.filter(item => item !== categoryName); nextCategories = nextCategories.filter(item => item !== categoryName);

View File

@ -2,7 +2,8 @@ import {useEffect, useState} from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import type {Gender, User} from '../../types/user'; import type {Gender, User} from '../../types/user';
import {Tag, TagsInput, TagsWrap} from './TagsInput'; import {Tag, TagsInput, TagsWrap} from './TagsInput';
import ym from "react-yandex-metrika";
import {reachGoal} from "../../hooks/useYM";
interface PersonModalProps { interface PersonModalProps {
isOpen: boolean; isOpen: boolean;
@ -46,7 +47,7 @@ export function PersonModal({
if (!isOpen) return; if (!isOpen) return;
if (user) { if (user) {
ym('reachGoal', 'ac_family_edit'); reachGoal('ac_family_edit');
setForm({ setForm({
name: user.name, name: user.name,
gender: user.gender, gender: user.gender,
@ -116,22 +117,32 @@ export function PersonModal({
{showExpanded ? ( {showExpanded ? (
<Content onClick={event => event.stopPropagation()}> <Content onClick={event => event.stopPropagation()}>
<Header> <Header>
<h2>{showExpanded == 'favorite' ? 'Любимые' : 'Не добавлять'}</h2> <h2>{showExpanded == 'favorite' ? 'Любимые' : 'Исключить'}</h2>
<CloseButton type="button" onClick={() => setShowExpanded('')}> <CloseButton type="button" onClick={() => setShowExpanded('')}>
× ×
</CloseButton> </CloseButton>
</Header> </Header>
<FormGroup> <FormGroup>
<TagsWrap> <TagsWrap>
{((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).map(tag => ( {/*{((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).map(tag => (*/}
<Tag key={tag} $type={showExpanded}> {availableTags.map(tag => (
{tag} <Tag
<button type="button" key={tag}
onClick={() => (showExpanded == 'favorite' ? handleFavoriteChange : handleAvoidChange)( $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) ((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).filter(item => item != tag)
)}> )
× } else {
</button> (showExpanded == 'favorite' ? handleFavoriteChange : handleAvoidChange)(
((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).concat(tag)
)
}
}}
>
{tag}
</Tag> </Tag>
))} ))}
</TagsWrap> </TagsWrap>
@ -208,7 +219,7 @@ export function PersonModal({
<PrefsGrid> <PrefsGrid>
<TagsInput <TagsInput
title="Не добавлять" title="Исключить"
type="avoid" type="avoid"
value={form.avoided} value={form.avoided}
availableTags={availableTags} availableTags={availableTags}
@ -238,6 +249,7 @@ export function PersonModal({
} }
const Overlay = styled.div` const Overlay = styled.div`
height: 100vh;
position: fixed; position: fixed;
inset: 0; inset: 0;
background: rgba(0, 0, 0, 0.4); background: rgba(0, 0, 0, 0.4);
@ -354,6 +366,7 @@ const GenderButton = styled.button<{ $active: boolean }>`
cursor: pointer; cursor: pointer;
transition: 0.1s; transition: 0.1s;
font-weight: 370; font-weight: 370;
height: 44px;
&:hover { &:hover {
outline: 1px solid #5EA12D; outline: 1px solid #5EA12D;
@ -363,6 +376,7 @@ const GenderButton = styled.button<{ $active: boolean }>`
const PrefsGrid = styled.div` const PrefsGrid = styled.div`
display: flex; display: flex;
flex-direction: column;
gap: 16px; gap: 16px;
margin-bottom: 20px; margin-bottom: 20px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;

View File

@ -1,5 +1,5 @@
import { useMemo, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import {declension} from "../../utils/declension";
interface TagsInputProps { interface TagsInputProps {
title: string; title: string;
@ -13,88 +13,92 @@ interface TagsInputProps {
export function TagsInput({ export function TagsInput({
title, title,
type,
value, value,
availableTags,
excludedTags,
onChange,
onExpand, onExpand,
}: TagsInputProps) { }: 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 ( return (
<Row>
<Column> <Column>
<Title>{title}</Title> <Title>{title}</Title>
<Info>{value.length > 0 ? declension(value.length, ['Выбрана', 'Выбраны', 'Выбраны']) + ': ' + value.length : 'Не выбраны'}</Info>
<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> </Column>
<AddWrapper>
<Button
type="button"
onClick={onExpand}
>
Выбрать
</Button>
</AddWrapper>
</Row>
); );
} }
const Column = styled.div` const AddWrapper = styled.div`
flex: 1; width: 100%;
position: relative; 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` const Title = styled.span`
display: block; display: block;
font-size: 16px; font-size: 16px;
@ -103,73 +107,46 @@ const Title = styled.span`
margin-bottom: 8px; 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` export const TagsWrap = styled.div`
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 6px; 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; display: inline-flex;
align-items: center; align-items: center;
padding: 4px 10px; padding: 4px 10px;
border-radius: 6px; border-radius: 6px;
font-size: 13px; font-size: 13px;
background-color: ${({ $type }) => ($type === 'avoid' ? '#fce4ec' : '#f0f4c3')}; background-color: ${({$type,$active}) => palette[$active ? 'active' : 'inactive'][$type].bg};
color: ${({ $type }) => ($type === 'avoid' ? '#c62828' : '#33691e')}; color: ${({$type,$active}) => palette[$active ? 'active' : 'inactive'][$type].text};
button { button {
margin-left: 6px;
cursor: pointer; cursor: pointer;
font-size: 15px; font-size: 15px;
opacity: 0.6; 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` const Label = styled.span`
position: absolute; position: absolute;
font-size: 16px; font-size: 16px;
color: #333333; color: #0C3B2E;
@media (max-width: 768px) { @media (max-width: 768px) {
font-size: 14px; font-size: 14px;

View File

@ -42,6 +42,7 @@ const Wrapper = styled.div<{ $state: InputState; $allowClear: boolean }>`
/* Default State */ /* Default State */
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
&:focus-within { &:focus-within {
border-color: #b3b3b3; border-color: #b3b3b3;
} }
@ -49,21 +50,12 @@ const Wrapper = styled.div<{ $state: InputState; $allowClear: boolean }>`
/* Success State */ /* Success State */
${({$state}) => $state === 'success' && css` ${({$state}) => $state === 'success' && css`
border: 2px solid #4CAF50; border: 2px solid #4CAF50;
`} `} /* Error State */ ${({$state}) => $state === 'error' && css`
/* Error State */
${({ $state }) => $state === 'error' && css`
border: 2px solid #F44336; border: 2px solid #F44336;
`} `} /* Disabled State */ ${({$state}) => $state === 'disabled' && css`
/* Disabled State */
${({ $state }) => $state === 'disabled' && css`
background-color: #f2f2f2; background-color: #f2f2f2;
border: 1px solid #e0e0e0; border: 1px solid #e0e0e0;
`} `} /* Padding adjustments for clear button */ ${({$allowClear}) => $allowClear && css`
/* Padding adjustments for clear button */
${({ $allowClear }) => $allowClear && css`
input { input {
padding-right: 40px; padding-right: 40px;
} }
@ -77,12 +69,20 @@ const Input = styled.input<{ $state: InputState }>`
outline: none; outline: none;
background: transparent; background: transparent;
font-size: 16px; font-size: 16px;
color: #333; color: #0C3B2E;
&::placeholder { &::placeholder {
color: #A3ADA2; color: #A3ADA2;
} }
&:hover {
border-color: #72b541;
}
&:focus {
border-color: #72b541;
}
${({$state}) => $state === 'disabled' && css` ${({$state}) => $state === 'disabled' && css`
color: #9e9e9e; color: #9e9e9e;
cursor: not-allowed; cursor: not-allowed;
@ -101,7 +101,7 @@ const ClearButton = styled.button<{ $state: InputState }>`
line-height: 1; line-height: 1;
&:hover { &:hover {
color: #333; color: #0C3B2E;
} }
${({$state}) => $state === 'disabled' && css` ${({$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 { normalizeProductImage } from '../utils/product';
import type { Product } from '../types/cart'; import type { Product } from '../types/cart';
import {useGlobalZustandState} from "./useGlobalZustandState"; import {useGlobalZustandState} from "./useGlobalZustandState";
import ym from "react-yandex-metrika"; import {reachGoal} from "./useYM";
interface UseCartPollingParams { interface UseCartPollingParams {
enabled: boolean; enabled: boolean;
onProductsChange: Dispatch<SetStateAction<Product[]>>; onProductsChange: Dispatch<SetStateAction<Product[]>>;
onComplete: () => void; onComplete: () => void;
onMistake: () => void;
onLoadingChange: (isLoading: boolean) => void; onLoadingChange: (isLoading: boolean) => void;
setDisplayText: Dispatch<SetStateAction<string>>; setDisplayText: Dispatch<SetStateAction<string>>;
} }
@ -115,6 +117,7 @@ export function useCartPolling({
onComplete, onComplete,
onLoadingChange, onLoadingChange,
setDisplayText, setDisplayText,
onMistake,
}: UseCartPollingParams) { }: UseCartPollingParams) {
const timeoutRef = useRef<number | null>(null); const timeoutRef = useRef<number | null>(null);
const {updateFromResponse, markAsComplete, markAsIncomplete} = useGlobalZustandState((state) => state); const {updateFromResponse, markAsComplete, markAsIncomplete} = useGlobalZustandState((state) => state);
@ -161,7 +164,7 @@ export function useCartPolling({
missing_scenario_element = -1; missing_scenario_element = -1;
if (update.status === 'complete') { if (update.status === 'complete') {
ym('reachGoal', 'ac_cart_complete'); reachGoal('ac_cart_complete');
onLoadingChange(false); onLoadingChange(false);
onComplete(); onComplete();
updateFromResponse(true, update.payload?.excess || 0, update.payload?.precision || 0); 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; 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( createRoot(root).render(
<React.StrictMode>
<Provider store={store}> <Provider store={store}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<GlobalStyle /> <GlobalStyle />
<App /> <App />
<YMInitializer
accounts={[68914120]}
options={{ clickmap: true, trackLinks: true, accurateTrackBounce: true, webvisor: true }}
/>
</ThemeProvider> </ThemeProvider>
</Provider> </Provider>,
</React.StrictMode>,
); );

View File

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

View File

@ -30,5 +30,16 @@
<body> <body>
<div id="root"></div> <div id="root"></div>
<script nonce="**MY_PRETTY_CSP_NONCE**" type="module" src="/src/main.tsx"></script> <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> </body>
</html> </html>

View File

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