diff --git a/images/broc-fail.png b/images/broc-fail.png new file mode 100644 index 0000000..ddf5726 Binary files /dev/null and b/images/broc-fail.png differ diff --git a/src/App.tsx b/src/App.tsx index ed574cb..ec1306d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(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('family'); const [needRefresh, setNeedRefresh] = useState(true); const [isFullnessHighlighted, setIsFullnessHighlighted] = useState(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 ( + {showLoader &&
} -

Хватило

+

Кладило

setMobileShowFilters(true)}> @@ -140,6 +144,7 @@ export function App() { setIsCartLoading(false); }} onLoadingChange={setIsCartLoading} + onMistake={() => setNeedRefresh(true)} />
@@ -209,7 +214,7 @@ export function App() { icon={ } - 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') }} > - +
@@ -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; diff --git a/src/api/promoApi.ts b/src/api/promoApi.ts index a78e292..7cc150c 100644 --- a/src/api/promoApi.ts +++ b/src/api/promoApi.ts @@ -124,11 +124,20 @@ export function subscribeOnFinish(email: string): Promise { }); } +export function subscribeOnFail(email: string, comment: string): Promise { + 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, diff --git a/src/components/Cart/Cart.tsx b/src/components/Cart/Cart.tsx index 86fdef9..157579f 100644 --- a/src/components/Cart/Cart.tsx +++ b/src/components/Cart/Cart.tsx @@ -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>; 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(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({
- Ваша корзина на {days} {declension(days, ['день', 'дня', 'дней'])} + Ваша закупка на {days} {declension(days, ['день', 'дня', 'дней'])} {showFullRefreshButton && Начать заново} @@ -122,6 +127,11 @@ export function Cart({ onDelete={() => handleDelete(product)} /> ))} + {products.length === visibleProducts.length && isComplete && !isLoading && + setShowNotEnoughPopup(true)}> + Не хватило + + } {products.length === 0 && ( @@ -155,6 +165,8 @@ export function Cart({ setSelectedProduct(null); }} /> + + {showNotEnoughPopup && {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; `; \ No newline at end of file diff --git a/src/components/Cart/CartFooter.tsx b/src/components/Cart/CartFooter.tsx index d0804a9..e1c3550 100644 --- a/src/components/Cart/CartFooter.tsx +++ b/src/components/Cart/CartFooter.tsx @@ -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) { { - ym('reachGoal', 'ac_hvatilo') + reachGoal('ac_hvatilo') setShowCompletePopup(true) }}> - {users.length > 1 ? 'Нам' : 'Мне'} хватило + Хватило {showCompletePopup && <> diff --git a/src/components/Cart/CartItem.tsx b/src/components/Cart/CartItem.tsx index f8b691f..5e8d2d8 100644 --- a/src/components/Cart/CartItem.tsx +++ b/src/components/Cart/CartItem.tsx @@ -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 ( @@ -148,6 +150,7 @@ const MenuButton = styled.button` `; const Overlay = styled.div` + height: 100vh; position: fixed; display: flex; inset: 0; diff --git a/src/components/Cart/FinitaLaCommedia.tsx b/src/components/Cart/FinitaLaCommedia.tsx index 31b0cdd..1c5a37c 100644 --- a/src/components/Cart/FinitaLaCommedia.tsx +++ b/src/components/Cart/FinitaLaCommedia.tsx @@ -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; diff --git a/src/components/Cart/NotEnoughPopup.tsx b/src/components/Cart/NotEnoughPopup.tsx new file mode 100644 index 0000000..b24ad63 --- /dev/null +++ b/src/components/Cart/NotEnoughPopup.tsx @@ -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(""); + const [value, setValue] = useState(''); + const [sent, setSent] = useState(false); + + return + + onClose()}> + × + + + {sent ? <> +

Сообщение отправлено

+ +

+ Спасибо, ваша помощь крайне важна для нас, всё записали! +

+ + : <> +

Чего-то не хватило?

+ +

+ Расскажите, что не так с текущей корзиной — мы постараемся учесть ваши пожелания. За отзыв + подарим 3 бесплатные корзины +

+ + + + ) => { + setValue(e.target.value); + }}> + + + subscribeOnFail(email, value).then(() => {setSent(true)})}>Отправить + } +
+
+
+}; + +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; + } +`; \ No newline at end of file diff --git a/src/components/Cart/OrderPopup.tsx b/src/components/Cart/OrderPopup.tsx index 31e5503..560817a 100644 --- a/src/components/Cart/OrderPopup.tsx +++ b/src/components/Cart/OrderPopup.tsx @@ -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) => { Включена в итоговую стоимость заказа { - ym('reachGoal', 'ac_order_button') + reachGoal('ac_order_button') setShowFinal(true) }}>Продолжить @@ -32,6 +33,7 @@ export const OrderPopup = ({setShowCompletePopup}: any) => { } const Overlay = styled.div` + height: 100vh; position: fixed; display: flex; inset: 0; diff --git a/src/components/Cart/ProductModal.tsx b/src/components/Cart/ProductModal.tsx index baea0ac..9651041 100644 --- a/src/components/Cart/ProductModal.tsx +++ b/src/components/Cart/ProductModal.tsx @@ -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; diff --git a/src/components/Categories/CategoriesSection.tsx b/src/components/Categories/CategoriesSection.tsx index 6cfc46e..eecc48b 100644 --- a/src/components/Categories/CategoriesSection.tsx +++ b/src/components/Categories/CategoriesSection.tsx @@ -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(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); diff --git a/src/components/Family/PersonModal.tsx b/src/components/Family/PersonModal.tsx index d209dbe..c794c76 100644 --- a/src/components/Family/PersonModal.tsx +++ b/src/components/Family/PersonModal.tsx @@ -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 ? ( event.stopPropagation()}>
-

{showExpanded == 'favorite' ? 'Любимые' : 'Не добавлять'}

+

{showExpanded == 'favorite' ? 'Любимые' : 'Исключить'}

setShowExpanded('')}> ×
- {((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).map(tag => ( - - {tag} - + ) + } else { + (showExpanded == 'favorite' ? handleFavoriteChange : handleAvoidChange)( + ((showExpanded == 'favorite' ? form.favorite : form.avoided) || []).concat(tag) + ) + } + }} + > + {tag} ))} @@ -208,7 +219,7 @@ export function PersonModal({ ` 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; diff --git a/src/components/Family/TagsInput.tsx b/src/components/Family/TagsInput.tsx index 4f6a50d..f4a2eae 100644 --- a/src/components/Family/TagsInput.tsx +++ b/src/components/Family/TagsInput.tsx @@ -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 ( - - {title} - - setQuery(event.target.value)} - /> - - {matches.length > 0 && ( - - {matches.map(match => ( - - ))} - - )} - - - {value.slice(0, 4).map(tag => ( - - {tag} - - - ))} - {value.length > 4 && onExpand()}> - +{value.length - 4} - - - - } - - + + + {title} + {value.length > 0 ? declension(value.length, ['Выбрана', 'Выбраны', 'Выбраны']) + ': ' + value.length : 'Не выбраны'} + + + + + ); } -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')}; - } -` \ No newline at end of file diff --git a/src/components/Fullness/FullnessSlider.tsx b/src/components/Fullness/FullnessSlider.tsx index dfbaf57..b56d4e1 100644 --- a/src/components/Fullness/FullnessSlider.tsx +++ b/src/components/Fullness/FullnessSlider.tsx @@ -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; diff --git a/src/components/components/TextInput.tsx b/src/components/components/TextInput.tsx index 4d91bb5..cd5a6ea 100644 --- a/src/components/components/TextInput.tsx +++ b/src/components/components/TextInput.tsx @@ -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 = ({ diff --git a/src/components/components/Textarea.tsx b/src/components/components/Textarea.tsx new file mode 100644 index 0000000..55cfff8 --- /dev/null +++ b/src/components/components/Textarea.tsx @@ -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) => void; + placeholder?: string; + maxLength?: number; +} + +const FeedbackTextarea: React.FC = ({ + value, + onChange, + placeholder = 'Напишите, что бы вы хотели поменять', + maxLength = 250, + }) => { + return ( + + + + {value.length}/{maxLength} + + + ); +}; + +export default FeedbackTextarea; \ No newline at end of file diff --git a/src/hooks/useCartPolling.ts b/src/hooks/useCartPolling.ts index 6af3aa8..8d8b4eb 100644 --- a/src/hooks/useCartPolling.ts +++ b/src/hooks/useCartPolling.ts @@ -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>; onComplete: () => void; + onMistake: () => void; onLoadingChange: (isLoading: boolean) => void; setDisplayText: Dispatch>; } @@ -115,6 +117,7 @@ export function useCartPolling({ onComplete, onLoadingChange, setDisplayText, + onMistake, }: UseCartPollingParams) { const timeoutRef = useRef(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; }); diff --git a/src/hooks/useYM.ts b/src/hooks/useYM.ts new file mode 100644 index 0000000..0d649fe --- /dev/null +++ b/src/hooks/useYM.ts @@ -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; +}; \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index f3cf0d8..ee740cd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -16,16 +16,10 @@ if (!root) { createRoot(root).render( - - - - - - - - - , + + + + + + , ); \ No newline at end of file diff --git a/src/styles/GlobalStyle.ts b/src/styles/GlobalStyle.ts index a519f46..4cca47a 100644 --- a/src/styles/GlobalStyle.ts +++ b/src/styles/GlobalStyle.ts @@ -66,4 +66,9 @@ export const GlobalStyle = createGlobalStyle` transform: rotate(360deg); } } + + *::placeholder { + font-weight: 300; + font-size: 16px; + } `; \ No newline at end of file diff --git a/src/template.html b/src/template.html index bbe25b4..7096100 100644 --- a/src/template.html +++ b/src/template.html @@ -30,5 +30,16 @@
+ + \ No newline at end of file diff --git a/src/types/api.ts b/src/types/api.ts index 1b8b7f1..fde08eb 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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; }; } \ No newline at end of file