diff --git a/figma/Frame 1340414245.svg b/figma/Frame 1340414245.svg new file mode 100644 index 0000000..0c40b97 --- /dev/null +++ b/figma/Frame 1340414245.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/figma/Frame 1340414252.svg b/figma/Frame 1340414252.svg new file mode 100644 index 0000000..87b550c --- /dev/null +++ b/figma/Frame 1340414252.svg @@ -0,0 +1,4 @@ + + + + diff --git a/figma/Frame 1340414261.png b/figma/Frame 1340414261.png new file mode 100644 index 0000000..2ac4601 Binary files /dev/null and b/figma/Frame 1340414261.png differ diff --git a/figma/Frame 1340414263.svg b/figma/Frame 1340414263.svg new file mode 100644 index 0000000..ce48f11 --- /dev/null +++ b/figma/Frame 1340414263.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/figma/Home Indicator.svg b/figma/Home Indicator.svg new file mode 100644 index 0000000..a21f095 --- /dev/null +++ b/figma/Home Indicator.svg @@ -0,0 +1,3 @@ + + + diff --git a/figma/export.zip b/figma/export.zip new file mode 100644 index 0000000..16874e4 Binary files /dev/null and b/figma/export.zip differ diff --git a/figma/onboarding_img1.png b/figma/onboarding_img1.png new file mode 100644 index 0000000..e1cd93a Binary files /dev/null and b/figma/onboarding_img1.png differ diff --git a/figma/onboarding_img1.webp b/figma/onboarding_img1.webp new file mode 100644 index 0000000..f449617 Binary files /dev/null and b/figma/onboarding_img1.webp differ diff --git a/figma/onboarding_img2.webp b/figma/onboarding_img2.webp new file mode 100644 index 0000000..38d954e Binary files /dev/null and b/figma/onboarding_img2.webp differ diff --git a/package-lock.json b/package-lock.json index efa8389..cc52317 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,10 @@ "version": "0.0.0", "dependencies": { "@tanstack/react-query": "^5.66.3", + "konva": "^9.3.20", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-konva": "^19.0.3", "react-router-dom": "^7.1.5", "zustand": "^5.0.3" }, @@ -1421,7 +1423,6 @@ "version": "19.0.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1437,6 +1438,15 @@ "@types/react": "^19.0.0" } }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.24.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.0.tgz", @@ -1913,7 +1923,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2459,6 +2468,18 @@ "dev": true, "license": "ISC" }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2536,6 +2557,26 @@ "json-buffer": "3.0.1" } }, + "node_modules/konva": { + "version": "9.3.20", + "resolved": "https://registry.npmjs.org/konva/-/konva-9.3.20.tgz", + "integrity": "sha512-7XPD/YtgfzC8b1c7z0hhY5TF1IO/pBYNa29zMTA2PeBaqI0n5YplUeo4JRuRcljeAF8lWtW65jePZZF7064c8w==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT" + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -2854,6 +2895,52 @@ "react": "^19.0.0" } }, + "node_modules/react-konva": { + "version": "19.0.3", + "resolved": "https://registry.npmjs.org/react-konva/-/react-konva-19.0.3.tgz", + "integrity": "sha512-MxaGCwKCo9DiGBPfsJKU1JShZ9YRsCjrJNF/KVyyzBB7UjrRtY7s46hH1Obr70y4trYZzFuqmW3ljB/mO0dAfA==", + "funding": [ + { + "type": "patreon", + "url": "https://www.patreon.com/lavrton" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/konva" + }, + { + "type": "github", + "url": "https://github.com/sponsors/lavrton" + } + ], + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9", + "its-fine": "^2.0.0", + "react-reconciler": "0.31.0", + "scheduler": "0.25.0" + }, + "peerDependencies": { + "konva": "^8.0.1 || ^7.2.5 || ^9.0.0", + "react": "^18.3.1 || ^19.0.0", + "react-dom": "^18.3.1 || ^19.0.0" + } + }, + "node_modules/react-reconciler": { + "version": "0.31.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz", + "integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.25.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", diff --git a/package.json b/package.json index 716df8d..43a1afc 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,17 @@ "type": "module", "scripts": { "dev": "vite", + "dev:network": "vite --host 0.0.0.0 --port 5173", "build": "tsc -b && vite build", "lint": "eslint .", "preview": "vite preview" }, "dependencies": { "@tanstack/react-query": "^5.66.3", + "konva": "^9.3.20", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-konva": "^19.0.3", "react-router-dom": "^7.1.5", "zustand": "^5.0.3" }, diff --git a/payment_request.json b/payment_request.json new file mode 100644 index 0000000..82be07c --- /dev/null +++ b/payment_request.json @@ -0,0 +1,4 @@ +{ + "user_id": 296487847, + "description": "стартовый набор" +} diff --git a/src/App.tsx b/src/App.tsx index 51ed7c5..8b5dee1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ const CreateStickerPack = lazy(() => import('./screens/CreateStickerPack')); const AddStickerToPackScreen = lazy(() => import('./screens/AddStickerToPackScreen')); const History = lazy(() => import('./screens/History')); const CropPhoto = lazy(() => import('./screens/CropPhoto')); +const StickerEditorScreen = lazy(() => import('./screens/StickerEditorScreen')); // Компонент для отображения состояния загрузки const LoadingScreen = () => ( @@ -69,14 +70,12 @@ const AppContent: React.FC = () => { const hasSeenOnboarding = localStorage.getItem('hasSeenOnboarding') === 'true'; const hasAcceptedTerms = localStorage.getItem('hasAcceptedTerms') === 'true'; - // Если не видел онбординг и не на странице онбординга или условий + // Если не видел онбординг и не на странице онбординга if (!hasSeenOnboarding && !location.pathname.includes('/onboarding')) { navigate('/onboarding/welcome'); } - // Если видел онбординг, но не принял условия и не на странице условий - else if (hasSeenOnboarding && !hasAcceptedTerms && !location.pathname.includes('/onboarding/terms')) { - navigate('/onboarding/terms'); - } + // Условие перенаправления на экран Terms and Conditions удалено, + // так как пользователь соглашается с условиями на экране How-to }, []); // Отслеживаем изменение маршрута для аналитики @@ -156,6 +155,11 @@ const AppContent: React.FC = () => { } /> } /> } /> + }> + + + } /> ); diff --git a/src/assets/index.ts b/src/assets/index.ts index 27da098..dcc2ac4 100644 --- a/src/assets/index.ts +++ b/src/assets/index.ts @@ -7,6 +7,8 @@ import emotions from './emotions_promo250x.webp'; import realism from './realism_promo250x.webp'; import prompt from './prompt.webp'; import onboard1 from './onboard1.webp'; +import onboardingWelcome from './onboarding_img1.webp'; +import onboarding_img2 from './onboarding_img2.webp'; import santa from './250x_santa.webp'; import balloon from './balloon250x.webp'; import bicycle from './bicecle250x.webp'; @@ -86,6 +88,10 @@ import emoDrStrange from './emo/35.webp'; import emoCaptainAmerica from './emo/36.webp'; export const images = { + onboarding_img2, + scateboard250x: skateboard, + fairy250x: fairy, + emotions_promo250x: emotions, ahareBot, faq, shorts, @@ -95,6 +101,7 @@ export const images = { realism, prompt, onboard1, + onboardingWelcome, santa, balloon, bicycle, diff --git a/src/assets/onboarding_img1.webp b/src/assets/onboarding_img1.webp new file mode 100644 index 0000000..f449617 Binary files /dev/null and b/src/assets/onboarding_img1.webp differ diff --git a/src/assets/onboarding_img2.webp b/src/assets/onboarding_img2.webp new file mode 100644 index 0000000..38d954e Binary files /dev/null and b/src/assets/onboarding_img2.webp differ diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index 52f0610..555ee79 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -105,7 +105,7 @@ const Header: React.FC = () => { onClose={() => setShowTokensModal(false)} onShowAllPacks={() => navigate('/profile')} missingTokens={0} - onBuyPack={(packId) => { + onBuyPack={(packId, showRub) => { const pack = tokenPacks.find(p => p.id === packId); if (!pack) return; @@ -119,33 +119,38 @@ const Header: React.FC = () => { setShowTokensModal(false); setLastPurchasedPack(pack); - paymentService.showBuyTokensPopup(pack, async (userData) => { - if (userData) { + if (showRub) { + // Используем оплату рублями + paymentService.showRubPaymentPopup(pack.title, () => { // Обновляем баланс через контекст updateBalance(); - - // Показываем модальное окно с информацией об успешной оплате - setNotificationTitle(getTranslation('payment_success')); - setNotificationMessage(getTranslation('tokens_purchased', pack.tokens + pack.bonusTokens)); - setShowNotificationModal(true); - } else { - // Если данные не получены, делаем запрос на получение данных пользователя - try { - // Получаем баланс пользователя - const balance = await apiService.getBalance(getCurrentUserId()); - - // Обновляем баланс через контекст - updateBalance(); - - // Показываем модальное окно с информацией об успешной оплате - setNotificationTitle(getTranslation('payment_success')); - setNotificationMessage(getTranslation('profile_tokens_purchased', pack.tokens + pack.bonusTokens, balance)); - setShowNotificationModal(true); - } catch (error) { - console.error('Ошибка при обновлении данных пользователя:', error); + + // Не показываем модальное окно с информацией об успешной оплате + // Просто обновляем баланс + }); + } else { + // Используем оплату звездами + paymentService.showBuyTokensPopup(pack, async (userData) => { + // Обновляем баланс через контекст, независимо от того, получены ли данные пользователя + updateBalance(); + + // Не показываем модальное окно с информацией об успешной оплате + // Просто обновляем баланс + + // Если возникла ошибка при получении данных, логируем её + if (!userData) { + try { + // Получаем баланс пользователя + await apiService.getBalance(getCurrentUserId()); + + // Обновляем баланс через контекст еще раз + updateBalance(); + } catch (error) { + console.error('Ошибка при обновлении данных пользователя:', error); + } } - } }); + } }} /> diff --git a/src/components/offerwall/OfferWallCard.module.css b/src/components/offerwall/OfferWallCard.module.css new file mode 100644 index 0000000..5a3f51b --- /dev/null +++ b/src/components/offerwall/OfferWallCard.module.css @@ -0,0 +1,409 @@ +.card { + border-radius: 24px; + background: #fff; + box-shadow: 0 2px 12px rgba(0,0,0,0.07); + padding: 20px 16px 16px 16px; + display: flex; + flex-direction: column; + position: relative; + min-height: 200px; + min-width: 0; + margin-bottom: 0; + transition: box-shadow 0.2s, transform 0.1s; + overflow: hidden; + color: #fff; + cursor: pointer; /* Указываем, что карточка кликабельна */ +} + +.card:hover { + box-shadow: 0 4px 24px rgba(0,0,0,0.13); + transform: translateY(-2px); +} + +.card:active { + transform: translateY(0); +} + +/* Стили для разных типов карточек */ +.popular { + background: #2196F3; + color: #fff; +} + +.bestValue { + background: #C62828; + color: #fff; +} + +.fullWidth { + grid-column: 1 / -1; + display: flex; + flex-direction: row; +} + +.fullWidth .content { + flex: 1; +} + +.fullWidth .image { + max-width: 40%; + height: auto; + margin: 0; + align-self: flex-end; + border-radius: 0; +} + +/* Бейджи */ +.badge { + position: absolute; + top: 0; + right: 0; + background: #fff; + color: #333; + font-size: 0.75rem; + font-weight: 600; + padding: 4px 8px; + border-bottom-left-radius: 12px; + z-index: 2; +} + +/* Класс .starBadge больше не используется, так как звездочка отображается через псевдоэлемент в родительском контейнере */ + +.badgeImg { + position: absolute; + top: 12px; + left: 12px; + height: 32px; + z-index: 2; +} + +/* Бейдж "Популярный выбор" */ +.popularBadge { + position: absolute; + top: 0; + right: 0; + background: #fff; + color: #333; + font-size: 0.7rem; + font-weight: 600; + padding: 4px 10px; + border-bottom-left-radius: 12px; + z-index: 2; +} + +/* Бейдж "Максимальная выгода" */ +.bestValueBadge { + position: absolute; + bottom: 0; + left: 0; + right: 0; + background: rgba(255, 255, 255, 0.2); + color: #fff; + font-size: 0.7rem; + font-weight: 600; + padding: 4px 10px; + text-align: center; + z-index: 2; +} + +/* Контент */ +.content { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + flex: 1; + height: 100%; + justify-content: space-between; +} + +.title { + font-size: 1.1rem; + font-weight: 700; + margin-bottom: 4px; + margin-top: 0; + text-transform: uppercase; + text-align: left; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; +} + +.bestValueLabel { + font-size: 0.9rem; + font-weight: 600; + color: #FFC107; + margin-left: auto; + text-transform: none; + white-space: nowrap; + margin-top: 18px; + margin-right: 4px; +} + +/* Белая отсекающая полоска под названием */ +.titleDivider { + width: 100%; + height: 1px; /* Увеличиваем высоту для лучшей видимости */ + background: linear-gradient(to right, transparent, rgba(255, 255, 255, 1), transparent); /* Делаем градиент более ярким */ + position: relative; /* Добавляем позиционирование */ + margin-top: 0px; + margin-bottom: 12px; + display: block; +} + +/* Информационный список */ +.infoList { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + margin-bottom: 8px; +} + +.infoItem { + display: flex; + align-items: center; + gap: 8px; + font-size: 0.95rem; + color: #fff; +} + +.bullet { + font-size: 1.5rem; + line-height: 0.5; +} + +.tokenInfo { + display: flex; + align-items: center; + gap: 4px; +} + +.tokenIcon { + width: 18px; + height: 18px; +} + +.image { + width: 100%; + max-width: 120px; + border-radius: 16px; + margin: 8px 0; + align-self: center; + object-fit: cover; +} + +.imageContainer { + position: absolute; + right: -16px; + bottom: -16px; + width: 120px; + height: 120px; + background-color: #4CAF50; + border-radius: 16px; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + z-index: 1; +} + +.imageContainer img { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Базовые стили для секции с ценой и кнопкой */ +.priceSection { + width: 100%; + margin-top: auto; +} + +/* Вертикальное расположение (для карточек в столбик) */ +.priceSectionVertical { + display: flex; + flex-direction: column; + align-items: center; /* Центрируем содержимое */ + gap: 12px; +} + +/* Стиль для символа рубля */ +.rubSymbol { + color: #FFC107; /* Желтый цвет */ + font-weight: 700; +} + +/* Стиль для символа звезды */ +.tokenSymbol { + color: #FFC107; /* Желтый цвет */ + font-weight: 700; +} + +/* Горизонтальное расположение (для карточек на всю ширину) */ +.priceSectionHorizontal { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; +} + +.price { + font-size: 1.8rem; + font-weight: 700; + color: inherit; +} + +.priceSectionVertical .price { + margin-bottom: 8px; +} + +.buyButton { + background: #fff; + color: #333; + border: none; + border-radius: 16px; + padding: 10px 20px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + text-align: center; +} + +.priceSectionVertical .buyButton { + width: 100%; +} + +.priceSectionHorizontal .buyButton { + width: auto; + min-width: 120px; +} + +.popular .buyButton, +.bestValue .buyButton { + background: #fff; + color: #333; +} + +.buyButton:hover { + opacity: 0.9; +} + +/* Стили для отображения цены */ +.priceWrapper { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: center; +} + +.originalPrice { + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.7); + text-decoration: line-through; +} + +.discount { + position: absolute; + top: 0; + right: 0; + font-size: 1.1rem; + font-weight: 700; + color: #FFC107; + background: rgba(0, 0, 0, 0.5); + padding: 4px 10px; + border-bottom-left-radius: 12px; + z-index: 3; +} + +/* Адаптивность */ +@media (max-width: 600px) { + .card { + padding: 12px 10px 10px 10px; + min-height: 140px; + border-radius: 16px; + } + + .title { + font-size: 0.9rem; + margin-bottom: 2px; + } + + .infoList { + gap: 4px; + margin-bottom: 4px; + } + + .infoItem { + font-size: 0.85rem; + gap: 4px; + } + + .bullet { + font-size: 1.2rem; + } + + .image { + max-width: 80px; + margin: 4px 0; + } + + .priceSection { + gap: 8px; + margin-top: 6px; + } + + .price { + font-size: 1.5rem; + } + + .buyButton { + padding: 6px 12px; + font-size: 0.9rem; + min-width: 80px; + border-radius: 12px; + } + + .badge { + font-size: 0.65rem; + padding: 3px 6px; + } + + .fullWidth { + flex-direction: column; + } + + .fullWidth .image { + max-width: 100%; + margin-top: 8px; + } +} + +@media (max-width: 320px) { + .card { + padding: 10px 8px 8px 8px; + min-height: 130px; + } + + .title { + font-size: 0.85rem; + } + + .infoItem { + font-size: 0.8rem; + } + + .price { + font-size: 1.3rem; + } + + .buyButton { + padding: 5px 10px; + font-size: 0.85rem; + min-width: 70px; + } +} diff --git a/src/components/offerwall/OfferWallCard.tsx b/src/components/offerwall/OfferWallCard.tsx new file mode 100644 index 0000000..564572a --- /dev/null +++ b/src/components/offerwall/OfferWallCard.tsx @@ -0,0 +1,176 @@ +import React, { forwardRef } from 'react'; +import styles from './OfferWallCard.module.css'; +import { images } from '../../assets'; + +export interface OfferWallCardProps { + title: string; + tokens: number; + bonusTokens: number; + stickersCount: number; + price: number; // Финальная цена в звездах + originalPrice: number; // Базовая цена в звездах без скидки + priceRub: number; // Финальная цена в рублях + originalPriceRub: number; // Базовая цена в рублях без скидки + discountPercent: number; // Процент скидки (0-100) + description: string; + isPopular?: boolean; + isBestValue?: boolean; + badgeImg?: string; // путь к картинке бейджа, если есть + image?: string; // путь к фото, если есть + onBuy?: () => void; + backgroundColor?: string; // цвет фона карточки + fullWidth?: boolean; // занимает ли карточка всю ширину + showRub?: boolean; // показывать ли цену в рублях или в токенах + // showStar?: boolean; // показывать ли звездочку в верхнем левом углу - больше не используется + bestValueLabel?: boolean; // показывать ли надпись "Максимальная выгода" рядом с заголовком +} + +const OfferWallCard = forwardRef((props, ref) => { + const { + title, + tokens, + bonusTokens, + stickersCount, + price, + originalPrice, + priceRub, + originalPriceRub, + discountPercent, + description, + isPopular, + isBestValue, + badgeImg, + image, + onBuy, + backgroundColor, + fullWidth = false, + showRub = false + } = props; + + // Определяем классы для карточки + const cardClasses = [ + styles.card, + isPopular ? styles.popular : '', + isBestValue ? styles.bestValue : '', + fullWidth ? styles.fullWidth : '' + ].filter(Boolean).join(' '); + + // Определяем стиль для карточки с кастомным цветом фона + const cardStyle = backgroundColor ? { backgroundColor } : {}; + + return ( +
+ {/* Бейджи */} + {isPopular && !badgeImg && ( +
Популярный выбор
+ )} + {isBestValue && !badgeImg && ( +
Максимальная выгода
+ )} + {badgeImg && ( + Бейдж + )} + {/* Звездочка теперь отображается через псевдоэлемент в родительском контейнере */} + {discountPercent > 0 && ( +
-{discountPercent}%
+ )} + +
+ {/* Заголовок с переносом второго слова на новую строку */} +

+
+ {title.split(' ').length > 1 ? ( + <> + {title.split(' ')[0]}
+ {title.split(' ').slice(1).join(' ')} + + ) : ( + title + )} +
+ {props.bestValueLabel && ( + Максимальная выгода + )} +

+ {/* Белая отсекающая полоска под названием */} +
+ + {/* Информация о токенах и стикерах */} +
+
+ + {stickersCount} стикеров +
+
+ +
+ {tokens} + Токены +
+
+ {bonusTokens > 0 && ( +
+ + {bonusTokens} бонусов +
+ )} +
+ + {/* Фото (если есть) */} + {image && ( +
+ Фото +
+ )} + + {/* Цена и кнопка */} +
+
+ {discountPercent > 0 && showRub ? ( + <> + + {originalPriceRub} + + + {priceRub} + + + ) : discountPercent > 0 && !showRub ? ( + <> + + {originalPrice} + + + {price} + + + ) : ( + + {showRub ? priceRub : price} + + {showRub ? '₽' : '⭐'} + + + )} +
+ +
+
+
+ ); +}); + +export default OfferWallCard; diff --git a/src/components/shared/OnboardingLayout.module.css b/src/components/shared/OnboardingLayout.module.css index e51650b..ee05a68 100644 --- a/src/components/shared/OnboardingLayout.module.css +++ b/src/components/shared/OnboardingLayout.module.css @@ -1,29 +1,27 @@ .container { min-height: 100vh; - height: 100vh; /* Фиксированная высота */ - overflow: hidden; /* Предотвращает скроллинг */ display: flex; flex-direction: column; - justify-content: center; + justify-content: flex-start; align-items: center; padding: var(--spacing-medium); - background-color: var(--color-background); + padding-top: var(--spacing-medium); + padding-bottom: 0; /* Убираем отступ снизу */ + background-color: #ffffff; /* Возвращаем белый цвет фону */ color: var(--color-text); } .content { max-width: 28rem; width: 100%; - max-height: 90vh; /* Ограничение высоты */ - overflow: auto; /* Если контент всё же не помещается, добавляем скролл только внутри контейнера */ - background-color: var(--color-surface); - border-radius: var(--border-radius); - padding: var(--spacing-large) var(--spacing-medium); - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + background-color: transparent; + padding: calc(var(--spacing-large) / 2) var(--spacing-medium) 0; /* Убираем отступ снизу */ color: var(--color-text); display: flex; flex-direction: column; animation: fadeIn 0.3s ease-in-out; + overflow-y: auto; /* Добавляем возможность скроллинга */ + max-height: 100vh; /* Ограничиваем высоту, чтобы появился скроллинг */ } @keyframes fadeIn { @@ -34,9 +32,14 @@ .title { font-size: 24px; font-weight: 700; - margin-bottom: var(--spacing-medium); + margin-bottom: var(--spacing-small); color: var(--color-text); text-align: center; + position: sticky; + top: 0; + z-index: 10; + background-color: #ffffff; /* Добавляем белый фон, чтобы текст не сливался с контентом при скроллинге */ + padding-top: var(--spacing-small); } .description { diff --git a/src/components/shared/OnboardingLayout.tsx b/src/components/shared/OnboardingLayout.tsx index b807e26..5a5256b 100644 --- a/src/components/shared/OnboardingLayout.tsx +++ b/src/components/shared/OnboardingLayout.tsx @@ -13,6 +13,8 @@ interface OnboardingLayoutProps { secondaryButtonText?: string; onPrimaryClick: () => void; onSecondaryClick?: () => void; + showProgressDots?: boolean; + showButtons?: boolean; } const OnboardingLayout: React.FC = ({ @@ -25,7 +27,9 @@ const OnboardingLayout: React.FC = ({ primaryButtonText, secondaryButtonText, onPrimaryClick, - onSecondaryClick + onSecondaryClick, + showProgressDots = true, + showButtons = true }) => { return (
@@ -48,25 +52,29 @@ const OnboardingLayout: React.FC = ({
)} - + {showProgressDots && ( + + )} -
- - - {secondaryButtonText && onSecondaryClick && ( + {showButtons && ( +
- )} -
+ + {secondaryButtonText && onSecondaryClick && ( + + )} +
+ )} ); diff --git a/src/components/tokens/TokenPacksModal.module.css b/src/components/tokens/TokenPacksModal.module.css index 93181d5..98fecd7 100644 --- a/src/components/tokens/TokenPacksModal.module.css +++ b/src/components/tokens/TokenPacksModal.module.css @@ -39,6 +39,53 @@ text-align: center; } +/* Переключатель цен */ +.priceToggle { + display: flex; + justify-content: center; + align-items: center; + background-color: #f0f0f0; + border-radius: 24px; + padding: 4px; + margin: 12px auto 16px; + max-width: 260px; + width: 100%; + border: 2px solid #4CAF50; /* Зеленая обводка */ +} + +.toggleButton { + flex: 1; + padding: 8px 12px; + border: none; + background: transparent; + border-radius: 20px; + font-size: 14px; + font-weight: 600; + color: #666; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +.toggleButton.active { + background-color: #4CAF50; + color: #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +.rubSymbol { + color: #FFC107; + font-weight: 700; +} + +.tokenSymbol { + color: #FFC107; + font-weight: 700; +} + .subtitle { margin: 0 0 16px 0; font-size: 14px; @@ -46,21 +93,18 @@ color: var(--color-text-secondary); } -.horizontalScroll { +/* Контейнер для одной карточки */ +.singleOfferContainer { display: flex; - overflow-x: auto; + justify-content: center; + width: 100%; padding: 8px 0; - -webkit-overflow-scrolling: touch; - scroll-snap-type: x mandatory; - gap: 16px; - margin: 0 -8px; - padding: 8px; + margin: 0 auto; } -.horizontalScroll > * { - flex-shrink: 0; - width: 280px; - scroll-snap-align: start; +.singleOfferContainer > div { + width: 100%; + max-width: 320px; } .showAllButton { @@ -103,21 +147,14 @@ background-color: rgba(0, 0, 0, 0.05); } -/* Стилизация скроллбара */ -.horizontalScroll::-webkit-scrollbar { - height: 6px; -} - -.horizontalScroll::-webkit-scrollbar-track { - background: var(--color-surface-variant); - border-radius: 3px; -} - -.horizontalScroll::-webkit-scrollbar-thumb { - background: var(--color-primary); - border-radius: 3px; -} - -.horizontalScroll::-webkit-scrollbar-thumb:hover { - background: var(--color-primary-dark); +/* Адаптивность */ +@media (max-width: 600px) { + .priceToggle { + max-width: 220px; + } + + .toggleButton { + font-size: 12px; + padding: 6px 8px; + } } diff --git a/src/components/tokens/TokenPacksModal.tsx b/src/components/tokens/TokenPacksModal.tsx index 5def6db..cab4bbd 100644 --- a/src/components/tokens/TokenPacksModal.tsx +++ b/src/components/tokens/TokenPacksModal.tsx @@ -1,15 +1,15 @@ -import React from 'react'; +import React, { useState } from 'react'; import { tokenPacks, TokenPack } from '../../constants/tokenPacks'; import styles from './TokenPacksModal.module.css'; -import TokenPacksList from './TokenPacksList'; import { getTranslation } from '../../constants/translations'; +import OfferWallCard from '../offerwall/OfferWallCard'; interface TokenPacksModalProps { isVisible: boolean; onClose: () => void; onShowAllPacks: () => void; missingTokens: number; - onBuyPack: (packId: string) => void; + onBuyPack: (packId: string, showRub: boolean) => void; } const TokenPacksModal: React.FC = ({ @@ -33,17 +33,54 @@ const TokenPacksModal: React.FC = ({ ); const displayPacks = tokenPacks.slice(startIndex, startIndex + 3); + // Состояние для переключателя "рубли/звезды" + const [showRub, setShowRub] = useState(true); + return (
e.stopPropagation()}> -

{getTranslation('token_modal_title')}

+

Докупить токенов

- + {/* Переключатель цен */} +
+ + +
-
+ + + )} div { + flex: 1; /* Карточки равномерно распределяются по высоте */ +} + +/* Стили для синей карточки (Стикерный энтузиаст) */ +.rightColumn > div:first-child { + flex: 1; /* Занимает все доступное пространство */ +} + +/* Стили для зеленой карточки с изображением */ +.imageCard { + aspect-ratio: 1 / 1; /* Сохраняем квадратную форму */ + flex-shrink: 0; /* Запрещаем сжиматься */ + margin-top: auto; /* Прижимаем к низу колонки */ +} + +/* Стили для карточек, занимающих всю ширину */ +.offersWrapper > div:nth-child(3), +.offersWrapper > div:nth-child(4) { + width: 100%; +} + +/* Стили для зеленой карточки с изображением */ +.imageCard { + background-color: #4CAF50; + border-radius: 24px; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + width: 100%; + max-width: 100%; + aspect-ratio: 1 / 1; /* Делаем карточку квадратной */ + position: relative; +} + +.imageCardImg { + position: absolute; + top: -13%; + left: -18%; /* Сдвигаем изображение вправо на 10% */ + width: 185%; /* Увеличиваем размер изображения на 20% */ + height: 185%; /* Увеличиваем размер изображения на 20% */ + object-fit: cover; /* Меняем с contain на cover для лучшего заполнения */ + padding: 0; /* Убираем padding, чтобы изображение могло выходить за края */ +} + +/* Добавляем класс для контейнера с JavaScript-корректировкой высоты */ +.dynamicHeightContainer { + position: relative; + width: 100%; +} + +/* Контейнер для карточки со звездочкой */ +.cardWithStar { + position: relative; + width: 100%; + height: 100%; +} + +/* Псевдоэлемент для отображения звездочки */ +.cardWithStar::before { + content: "★"; + position: absolute; + top: -30px; + left: -11px; + font-size: 2.8rem; + color: #FFC107; + z-index: 10; + -webkit-text-stroke: 1.5px black; + text-stroke: 1.5px black; + text-shadow: 0 0 3px rgba(0,0,0,0.5); + pointer-events: none; /* Чтобы звездочка не перехватывала клики */ +} + +@media (max-width: 600px) { + .offersWrapper { + gap: 8px; + padding: 2px; + } + + .priceToggle { + max-width: 260px; + } + + .toggleButton { + font-size: 12px; + padding: 6px 8px; + } + + /* На мобильных устройствах можно сделать колонки одинаковой высоты */ + .leftColumn, .rightColumn { + min-height: 520px; + } +} + +@media (max-width: 320px) { + .offersWrapper { + gap: 6px; + } + + /* Дополнительные корректировки для очень маленьких экранов */ + .offersWrapper > div:nth-child(1), + .offersWrapper > div:nth-child(3) { + height: 210px; + } + + .offersWrapper > div:nth-child(2) { + height: 430px; } } diff --git a/src/screens/Profile.tsx b/src/screens/Profile.tsx index 23a7436..1894f0f 100644 --- a/src/screens/Profile.tsx +++ b/src/screens/Profile.tsx @@ -1,14 +1,15 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } from 'react'; import styles from './Profile.module.css'; import { stickerService } from '../services/stickerService'; import apiService from '../services/api'; import { getCurrentUserId } from '../constants/user'; import customAnalyticsService from '../services/customAnalyticsService'; -import TokenPacksList from '../components/tokens/TokenPacksList'; import { tokenPacks, TokenPack } from '../constants/tokenPacks'; import { paymentService } from '../services/paymentService'; import NotificationModal from '../components/shared/NotificationModal'; import { getTranslation } from '../constants/translations'; +import OfferWallCard from '../components/offerwall/OfferWallCard'; +import { images } from '../assets'; const Profile: React.FC = () => { const [stickersCount, setStickersCount] = useState(0); @@ -17,6 +18,7 @@ const Profile: React.FC = () => { const [showPaymentSuccessModal, setShowPaymentSuccessModal] = useState(false); const [lastPurchasedPack, setLastPurchasedPack] = useState(null); const [userBalance, setUserBalance] = useState(0); + const [showRub, setShowRub] = useState(true); useEffect(() => { fetchUserData(); @@ -44,6 +46,24 @@ const Profile: React.FC = () => { } }; + // Функция для получения цвета карточки по ID + const getCardColor = (id: string) => { + switch (id) { + case 'basic': + return '#FF6B9E'; // розовый + case 'optimal': + return '#673AB7'; // фиолетовый + case 'advanced': + return '#2196F3'; // синий + case 'super': + return '#C62828'; // красный + case 'unlimited': + return '#1B5E20'; // темно-зеленый (премиальный) + default: + return undefined; + } + }; + const handleBuyPack = (packId: string) => { const pack = tokenPacks.find(p => p.id === packId); if (!pack) return; @@ -57,32 +77,38 @@ const Profile: React.FC = () => { setLastPurchasedPack(pack); - paymentService.showBuyTokensPopup(pack, async (userData) => { - if (userData) { - // Обновляем данные на основе полученной информации - if (userData.stickers_count !== undefined) { - setStickersCount(userData.stickers_count); - } - if (userData.packs_count !== undefined) { - setPacksCount(userData.packs_count); - } - if (userData.balance !== undefined) { - setUserBalance(userData.balance); - } - - // Показываем модальное окно с информацией об успешной оплате - setShowPaymentSuccessModal(true); - } else { - // Если данные не получены, делаем запрос на получение данных пользователя - try { - await fetchUserData(); - // Показываем модальное окно с информацией об успешной оплате - setShowPaymentSuccessModal(true); - } catch (error) { - console.error('Ошибка при обновлении данных пользователя:', error); - } + const handlePaymentSuccess = async (userData: any = null) => { + if (userData) { + // Обновляем данные на основе полученной информации + if (userData.stickers_count !== undefined) { + setStickersCount(userData.stickers_count); } - }); + if (userData.packs_count !== undefined) { + setPacksCount(userData.packs_count); + } + if (userData.balance !== undefined) { + setUserBalance(userData.balance); + } + + // Не показываем модальное окно после оплаты + } else { + // Если данные не получены, делаем запрос на получение данных пользователя + try { + await fetchUserData(); + // Не показываем модальное окно после оплаты + } catch (error) { + console.error('Ошибка при обновлении данных пользователя:', error); + } + } + }; + + if (showRub) { + // Используем оплату рублями + paymentService.showRubPaymentPopup(pack.title, handlePaymentSuccess); + } else { + // Используем оплату звездами + paymentService.showBuyTokensPopup(pack, handlePaymentSuccess); + } }; const handleClosePaymentSuccessModal = () => { @@ -111,11 +137,142 @@ const Profile: React.FC = () => {
- + {/* Переключатель цен */} +
+ + +
+ +
+ {/* Левая колонка */} +
+ {/* Стартовый набор (первая карточка) */} + handleBuyPack(tokenPacks[0].id)} + /> + + {/* Стикерный запас (третья карточка) */} +
+ handleBuyPack(tokenPacks[2].id)} + /> +
+
+ + {/* Правая колонка */} +
+ {/* Стикерный энтузиаст (вторая карточка) */} + handleBuyPack(tokenPacks[1].id)} + /> + + {/* Зеленая карточка с изображением (четвертая карточка) */} +
+ Эмоция +
+
+ + {/* Стикерный магнат (пятая карточка, на всю ширину) */} + handleBuyPack(tokenPacks[3].id)} + /> + + {/* Бог стикеров (шестая карточка, на всю ширину) */} + handleBuyPack(tokenPacks[4].id)} + /> +
{/* Модальное окно успешной оплаты */} { + const { stickerId } = useParams<{ stickerId: string }>(); + const navigate = useNavigate(); + const [stickerUrl, setStickerUrl] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [stickerImg, setStickerImg] = useState(null); + + // Получаем dataURL из location.state, если есть + const locationState = (window.history.state && window.history.state.usr) || {}; + const stickerDataUrl = locationState.stickerDataUrl as string | undefined; + + // Состояние объектов на холсте и undo/redo + const [objects, setObjects] = useState([]); + const [undoStack, setUndoStack] = useState([]); + const [redoStack, setRedoStack] = useState([]); + + // Загружаем изображение для Konva: сначала из dataURL, если есть, иначе fetch+blob+dataURL (обход CORS) + useEffect(() => { + let revoked = false; + if (stickerDataUrl) { + const img = new window.Image(); + img.src = stickerDataUrl; + img.onload = () => !revoked && setStickerImg(img); + img.onerror = () => !revoked && setStickerImg(null); + setStickerUrl(stickerDataUrl); + return () => { revoked = true; }; + } + if (!stickerUrl) return; + (async () => { + try { + const response = await fetch(stickerUrl, { mode: 'cors' }); + const blob = await response.blob(); + const dataUrl = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + if (revoked) return; + const img = new window.Image(); + img.src = dataUrl; + img.onload = () => !revoked && setStickerImg(img); + img.onerror = () => !revoked && setStickerImg(null); + } catch { + setStickerImg(null); + } + })(); + return () => { revoked = true; }; + }, [stickerUrl, stickerDataUrl]); + + useEffect(() => { + // Если есть dataURL, не делаем запрос к API + if (stickerDataUrl) { + setLoading(false); + setError(null); + return; + } + // Получаем стикер по id (ищем среди сгенерированных) + const fetchSticker = async () => { + try { + setLoading(true); + const images = await apiService.getGeneratedImages(); + const found = images.find(img => String(img.id) === String(stickerId)); + if (found && found.url) { + setStickerUrl(found.url); + } else { + setError('Стикер не найден'); + } + } catch (e) { + setError('Ошибка загрузки стикера'); + } finally { + setLoading(false); + } + }; + fetchSticker(); + }, [stickerId, stickerDataUrl]); + + // Undo/redo helpers + const pushUndo = useCallback((newObjects: EditorObject[]) => { + setUndoStack((stack) => [...stack, objects]); + setRedoStack([]); // сбрасываем redo при новом действии + setObjects(newObjects); + }, [objects]); + + const handleUndo = () => { + if (undoStack.length === 0) return; + const prev = undoStack[undoStack.length - 1]; + setRedoStack((stack) => [objects, ...stack]); + setUndoStack((stack) => stack.slice(0, -1)); + setObjects(prev); + }; + + const handleRedo = () => { + if (redoStack.length === 0) return; + const next = redoStack[0]; + setUndoStack((stack) => [...stack, objects]); + setRedoStack((stack) => stack.slice(1)); + setObjects(next); + }; + + // Добавить текст + const handleAddText = () => { + const id = 'text' + (Date.now()); + pushUndo([...objects, { ...initialText, id }]); + }; + + // Добавить эмодзи/стикер + const handleAddSticker = (src: string) => { + const id = 'sticker' + (Date.now()); + pushUndo([...objects, { type: 'sticker', id, src, x: 150, y: 150, width: 80, height: 80, rotation: 0 }]); + }; + + if (loading) { + return ( +
+ Загрузка стикера... +
+ ); + } + + if (error) { + return ( +
+ {error} + +
+ ); + } + + return ( +
+

Редактор стикеров (MVP)

+
+ + + {stickerImg && ( + + )} + {objects.map(obj => + obj.type === 'text' ? ( + { + const newObjects = objects.map(o => + o.id === obj.id + ? { ...o, x: e.target.x(), y: e.target.y() } + : o + ); + pushUndo(newObjects); + }} + /> + ) : ( + { + const img = new window.Image(); + img.src = obj.src; + return img; + })()} + x={obj.x} + y={obj.y} + width={obj.width} + height={obj.height} + rotation={obj.rotation} + draggable + onDragEnd={e => { + const newObjects = objects.map(o => + o.id === obj.id + ? { ...o, x: e.target.x(), y: e.target.y() } + : o + ); + pushUndo(newObjects); + }} + /> + ) + )} + + +
+
+ + + +
+ Эмодзи: + {editorStickers.map(st => ( + + ))} +
+
+ MVP: drag, undo/redo, добавление текста и эмодзи +
+ ); +}; + +export default StickerEditorScreen; diff --git a/src/screens/StickerPacks.module.css b/src/screens/StickerPacks.module.css index d70c506..dd422a5 100644 --- a/src/screens/StickerPacks.module.css +++ b/src/screens/StickerPacks.module.css @@ -9,6 +9,23 @@ height: 100%; /* Устанавливаем высоту */ } +/* Сетка для оффервола */ +.offersWrapper { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + width: 100%; + margin-top: 12px; + margin-bottom: 24px; +} + +@media (max-width: 600px) { + .offersWrapper { + grid-template-columns: 1fr; + gap: 12px; + } +} + .header { padding: var(--spacing-small) 0; text-align: center; diff --git a/src/screens/onboarding/OnboardingHowTo.module.css b/src/screens/onboarding/OnboardingHowTo.module.css index eb0e44c..442f9f9 100644 --- a/src/screens/onboarding/OnboardingHowTo.module.css +++ b/src/screens/onboarding/OnboardingHowTo.module.css @@ -1,79 +1,236 @@ -.stepsContainer { +.root { display: flex; flex-direction: column; - gap: var(--spacing-medium); + min-height: 100vh; + background: #fff; + box-sizing: border-box; + overflow-x: hidden; } -.step { +.imageBlock { + width: 100%; + max-width: 100%; + margin: 32px 0 0 0; + padding: 0; + overflow: visible; + position: relative; + z-index: 1; display: flex; - align-items: flex-start; - gap: var(--spacing-medium); - animation: fadeIn 0.3s ease-out; - animation-fill-mode: both; + flex-direction: column; + align-items: stretch; } -.step:nth-child(1) { - animation-delay: 0.1s; +.image { + width: 100%; + min-width: 100%; + max-width: 100%; + height: auto; + display: block; + margin: 0; + padding: 0; + border-radius: 0; + box-shadow: none; + object-fit: cover; + flex-shrink: 0; + flex-grow: 0; } -.step:nth-child(2) { - animation-delay: 0.2s; +.stepsBlock { + margin: 0 0 16px 0; + display: flex; + flex-direction: column; + gap: 12px; + align-items: center; + z-index: 2; } -.step:nth-child(3) { - animation-delay: 0.3s; +.stepRow { + display: flex; + align-items: center; + gap: 12px; } -.stepNumber { - width: 32px; - height: 32px; - border-radius: 50%; +.stepBadge { + background: #eafeea; + border-radius: 24px; + padding: 8px 18px; + font-weight: 700; + font-size: 18px; + color: #16b100; + white-space: nowrap; + margin-right: 8px; +} + +.stepBadgeBlue { + background: #eaf4fe; + color: #1976d2; +} + +.stepBadgeRed { + background: #feeaea; + color: #e53935; +} + +.stepImage { + width: 48px; + height: 48px; + border-radius: 16px; + object-fit: cover; + background: #fff; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); +} + +.contentWrapper { + display: flex; + flex-direction: column; + flex: 1 1 auto; + width: 100%; + padding: 0 16px; + box-sizing: border-box; + justify-content: center; + align-items: center; + min-height: 0; +} + +.title { + font-size: 24px; + font-weight: 800; + color: #111; + text-align: center; + margin-bottom: 12px; + margin-top: 0; + line-height: 1.18; + letter-spacing: -0.5px; +} + +.list { + margin: 0 0 18px 0; + padding-left: 0; + color: #222; + font-size: 16px; + text-align: center; + list-style: none; +} + +/* Progress bar */ +.progress { display: flex; justify-content: center; align-items: center; + gap: 8px; + margin: 18px 0 16px 0; +} + +.bottomContent { + width: 100%; + padding: 0 16px; + box-sizing: border-box; +} + +.dot { + width: 32px; + height: 8px; + border-radius: 4px; + background: #e0e0e0; + display: inline-block; + transition: background 0.2s; +} + +.dotActive { + background: #16b100; +} + +/* Button */ +.button { + width: 100%; + background: #eafeea; + color: #16b100; + font-size: 20px; font-weight: 700; - font-size: 16px; - color: white; + border: none; + border-radius: 12px; + padding: 14px 0; + margin: 0 0 12px 0; + cursor: pointer; + transition: background 0.2s, color 0.2s, opacity 0.2s; + box-shadow: none; + outline: none; +} + +.button:disabled { + background: #e0e0e0; + color: #bdbdbd; + cursor: not-allowed; + opacity: 1; +} + +/* Checkbox row */ +.checkboxRow { + display: flex; + align-items: flex-start; + justify-content: flex-start; + margin: 0 0 8px 0; + width: 100%; + padding: 0 4px; + box-sizing: border-box; +} + +.checkboxLabel { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 13px; + color: #222; + width: 100%; + cursor: pointer; + user-select: none; +} + +.checkbox { + display: none; +} + +.fakeCheckbox { + width: 18px; + height: 18px; + border: 2px solid #16b100; + border-radius: 4px; + background: #fff; + margin-top: 2px; + position: relative; flex-shrink: 0; } -.step:nth-child(1) .stepNumber { - background-color: var(--color-primary); +.checkbox:checked + .fakeCheckbox { + background: #16b100; + border-color: #16b100; } -.step:nth-child(2) .stepNumber { - background-color: var(--color-secondary); +.checkbox:checked + .fakeCheckbox::after { + content: ''; + display: block; + position: absolute; + left: 4px; + top: 0px; + width: 6px; + height: 12px; + border: solid #fff; + border-width: 0 2.5px 2.5px 0; + transform: rotate(45deg); } -.step:nth-child(3) .stepNumber { - background-color: var(--color-accent2); +.checkboxText { + display: inline; + line-height: 1.3; + color: #222; + font-size: 13px; + margin-left: 2px; + margin-right: 0; + word-break: break-word; } -.stepContent { - flex: 1; -} - -.stepTitle { - font-weight: 600; - font-size: 16px; - margin-bottom: var(--spacing-small); - color: var(--color-text); -} - -.stepDescription { - font-size: 14px; - color: var(--color-text-secondary); - line-height: 1.4; -} - -@media (max-width: 480px) { - .stepsContainer { - gap: var(--spacing-small); - } - - .stepNumber { - width: 28px; - height: 28px; - font-size: 14px; - } +.link { + color: #1976d2; + text-decoration: underline; + cursor: pointer; } diff --git a/src/screens/onboarding/OnboardingHowTo.tsx b/src/screens/onboarding/OnboardingHowTo.tsx index 0813f82..d5334a7 100644 --- a/src/screens/onboarding/OnboardingHowTo.tsx +++ b/src/screens/onboarding/OnboardingHowTo.tsx @@ -1,82 +1,86 @@ -import React, { useEffect } from 'react'; -import { useNavigate } from 'react-router-dom'; -import OnboardingLayout from '../../components/shared/OnboardingLayout'; -import styles from './OnboardingHowTo.module.css'; -import customAnalyticsService from '../../services/customAnalyticsService'; -import { getCurrentUserId } from '../../constants/user'; -import { getTranslation } from '../../constants/translations'; +import React, { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import styles from "./OnboardingHowTo.module.css"; +import { images } from "../../assets"; +import { getTranslation } from "../../constants/translations"; +import { Link } from "react-router-dom"; const OnboardingHowTo: React.FC = () => { const navigate = useNavigate(); - - useEffect(() => { - // Отслеживаем событие открытия экрана инструкции - customAnalyticsService.trackEvent({ - telegram_id: getCurrentUserId(), - event_category: 'navigation', - event_name: 'view_onboarding_how_to' - }); - }, []); + const [checked, setChecked] = useState(false); const handleNext = () => { - // Отслеживаем событие нажатия кнопки "Далее" - customAnalyticsService.trackEvent({ - telegram_id: getCurrentUserId(), - event_category: 'onboarding', - event_name: 'how_to_next_click' - }); - - navigate('/onboarding/sticker-packs'); - }; - - const handleSkip = () => { - // Отслеживаем событие нажатия кнопки "Пропустить" - customAnalyticsService.trackEvent({ - telegram_id: getCurrentUserId(), - event_category: 'onboarding', - event_name: 'how_to_skip_click' - }); - - localStorage.setItem('hasSeenOnboarding', 'true'); - navigate('/'); + if (checked) { + // Устанавливаем флаг, что пользователь принял условия + localStorage.setItem('hasAcceptedTerms', 'true'); + navigate("/onboarding/sticker-packs"); + } }; return ( - -
-
-
1
-
-

{getTranslation('upload_photo_title')}

-

{getTranslation('upload_photo_description')}

-
+
+
+ Онбординг шаги +
+
+
Нет ничего проще!
+
    +
  • Создавайте стикеры в три клика
  • +
  • Собирайте из них наборы
  • +
  • Публикуйте свои коллекции в телеграмм
  • +
+
+
+
+ + +
- -
-
2
-
-

{getTranslation('choose_style_title')}

-

{getTranslation('choose_style_description')}

-
-
- -
-
3
-
-

{getTranslation('create_sticker_title')}

-

{getTranslation('create_sticker_description')}

-
+ +
+
- +
); }; diff --git a/src/screens/onboarding/OnboardingStickerPacks.module.css b/src/screens/onboarding/OnboardingStickerPacks.module.css index 0279a0e..23cfd5b 100644 --- a/src/screens/onboarding/OnboardingStickerPacks.module.css +++ b/src/screens/onboarding/OnboardingStickerPacks.module.css @@ -66,14 +66,239 @@ line-height: 1.4; } -@media (max-width: 480px) { - .stepsContainer { - gap: var(--spacing-small); +/* Контейнер для хедера */ +.headerContainer { + position: fixed; + top: 0; + left: 0; + right: 0; + background-color: #ffffff; + z-index: 100; + padding: 12px 16px 4px; /* Уменьшаем отступы */ + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + align-items: center; +} + +/* Кнопка "Пропустить" */ +.skipButton { + position: absolute; + top: 12px; + right: 16px; + font-size: 14px; + font-weight: 500; + color: #666; + background: transparent; + border: none; + padding: 8px; + cursor: pointer; + z-index: 200; /* Выше, чем у headerContainer */ +} + +.skipButton:hover { + color: #333; +} + +/* Заголовок внутри хедера */ +.headerTitle { + font-size: 24px; + font-weight: 700; + margin-bottom: 8px; /* Уменьшаем отступ снизу */ + color: var(--color-text); + text-align: center; +} + +/* Переключатель цен */ +.priceToggle { + display: flex; + justify-content: center; + align-items: center; + background-color: #f0f0f0; + border-radius: 24px; + padding: 4px; + margin: 0 auto 8px; + max-width: 300px; + width: 100%; + border: 2px solid #4CAF50; /* Добавляем зеленую обводку */ +} + +.toggleButton { + flex: 1; + padding: 8px 12px; + border: none; + background: transparent; + border-radius: 20px; + font-size: 14px; + font-weight: 600; + color: #666; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; +} + +.toggleButton.active { + background-color: #4CAF50; + color: #fff; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +/* Удаляем правило, чтобы символ рубля оставался желтым даже при активной кнопке */ + +.rubSymbol { + color: #FFC107; + font-weight: 700; +} + +.tokenSymbol { + color: #FFC107; + font-weight: 700; +} + +/* Сетка для оффервола */ +.offersWrapper { + display: flex; + flex-wrap: wrap; + gap: 10px; + width: 100%; + margin-top: 35px; /* Еще больше уменьшаем отступ сверху */ + margin-bottom: 0; /* Убираем отступ снизу */ + padding: 4px; + padding-bottom: 40px; /* Умеренный отступ снизу */ +} + +/* Создаем две колонки */ +.leftColumn, .rightColumn { + display: flex; + flex-direction: column; + gap: 10px; + width: calc(50% - 5px); /* 50% ширины минус половина отступа */ +} + +/* Левая колонка должна занимать всю доступную высоту */ +.leftColumn { + flex: 1; +} + +/* Правая колонка должна подстраиваться под высоту левой */ +.rightColumn { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +/* Стили для карточек в левой колонке */ +.leftColumn > div { + flex: 1; /* Карточки равномерно распределяются по высоте */ +} + +/* Стили для синей карточки (Стикерный энтузиаст) */ +.rightColumn > div:first-child { + flex: 1; /* Занимает все доступное пространство */ +} + +/* Стили для зеленой карточки с изображением */ +.imageCard { + aspect-ratio: 1 / 1; /* Сохраняем квадратную форму */ + flex-shrink: 0; /* Запрещаем сжиматься */ + margin-top: auto; /* Прижимаем к низу колонки */ +} + +/* Стили для карточек, занимающих всю ширину */ +.offersWrapper > div:nth-child(3), +.offersWrapper > div:nth-child(4) { + width: 100%; + +} + +/* Стили для зеленой карточки с изображением */ +.imageCard { + background-color: #4CAF50; + border-radius: 24px; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + width: 100%; + max-width: 100%; + aspect-ratio: 1 / 1; /* Делаем карточку квадратной */ + position: relative; +} + +.imageCardImg { + position: absolute; + top: -13%; + left: -18%; /* Сдвигаем изображение вправо на 10% */ + width: 185%; /* Увеличиваем размер изображения на 20% */ + height: 185%; /* Увеличиваем размер изображения на 20% */ + object-fit: cover; /* Меняем с contain на cover для лучшего заполнения */ + padding: 0; /* Убираем padding, чтобы изображение могло выходить за края */ +} + +/* Добавляем класс для контейнера с JavaScript-корректировкой высоты */ +.dynamicHeightContainer { + position: relative; + width: 100%; +} + +@media (max-width: 600px) { + .offersWrapper { + gap: 8px; + padding: 2px; } - .stepNumber { - width: 28px; - height: 28px; - font-size: 14px; + .priceToggle { + max-width: 260px; + } + + .toggleButton { + font-size: 12px; + padding: 6px 8px; + } + + /* На мобильных устройствах можно сделать колонки одинаковой высоты */ + .leftColumn, .rightColumn { + min-height: 520px; } } + +@media (max-width: 320px) { + .offersWrapper { + gap: 6px; + } + + /* Дополнительные корректировки для очень маленьких экранов */ + .offersWrapper > div:nth-child(1), + .offersWrapper > div:nth-child(3) { + height: 210px; + } + + .offersWrapper > div:nth-child(2) { + height: 430px; + } +} + +/* Контейнер для карточки со звездочкой */ +.cardWithStar { + position: relative; + width: 100%; + height: 100%; +} + +/* Псевдоэлемент для отображения звездочки */ +.cardWithStar::before { + content: "★"; + position: absolute; + top: -30px; + left: -11px; + font-size: 2.8rem; + color: #FFC107; + z-index: 10; + -webkit-text-stroke: 1.5px black; + text-stroke: 1.5px black; + text-shadow: 0 0 3px rgba(0,0,0,0.5); + pointer-events: none; /* Чтобы звездочка не перехватывала клики */ +} diff --git a/src/screens/onboarding/OnboardingStickerPacks.tsx b/src/screens/onboarding/OnboardingStickerPacks.tsx index eda09be..a92038e 100644 --- a/src/screens/onboarding/OnboardingStickerPacks.tsx +++ b/src/screens/onboarding/OnboardingStickerPacks.tsx @@ -1,13 +1,50 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import OnboardingLayout from '../../components/shared/OnboardingLayout'; import styles from './OnboardingStickerPacks.module.css'; import customAnalyticsService from '../../services/customAnalyticsService'; import { getCurrentUserId } from '../../constants/user'; import { getTranslation } from '../../constants/translations'; +import { tokenPacks, TokenPack } from '../../constants/tokenPacks'; +import OfferWallCard from '../../components/offerwall/OfferWallCard'; +import { images } from '../../assets'; +import { paymentService } from '../../services/paymentService'; +import { useBalance } from '../../contexts/BalanceContext'; +import { updateBalanceWithRetries } from '../../utils/balanceUtils'; const OnboardingStickerPacks: React.FC = () => { const navigate = useNavigate(); + const [showRub, setShowRub] = useState(true); + const leftColumnRef = useRef(null); + const rightColumnRef = useRef(null); + const greenCardRef = useRef(null); + const { updateBalance } = useBalance(); + + // Обработчик покупки пакета токенов + const handleBuyPack = (pack: TokenPack) => { + // Аналитика: попытка оплаты + customAnalyticsService.trackEvent({ + telegram_id: getCurrentUserId(), + event_category: 'payment', + event_name: 'purchase_attempt' + }); + + if (showRub) { + // Используем оплату рублями + paymentService.showRubPaymentPopup(pack.title, () => { + // Обновляем баланс с повторными попытками + updateBalanceWithRetries(updateBalance); + }); + } else { + // Используем оплату звездами + paymentService.showBuyTokensPopup(pack, async (userData) => { + // Обновляем баланс с повторными попытками + updateBalanceWithRetries(updateBalance); + }); + } + }; + + // Функция для корректировки высоты карточек больше не нужна, так как мы используем flexbox useEffect(() => { // Отслеживаем событие открытия экрана стикерпаков @@ -25,56 +62,193 @@ const OnboardingStickerPacks: React.FC = () => { event_category: 'onboarding', event_name: 'sticker_packs_start_click' }); - + // Устанавливаем флаг, что пользователь видел онбординг localStorage.setItem('hasSeenOnboarding', 'true'); - - // Проверяем, принял ли пользователь уже условия использования - const hasAcceptedTerms = localStorage.getItem('hasAcceptedTerms') === 'true'; - - if (hasAcceptedTerms) { - // Если уже принял условия, переходим на главный экран - navigate('/'); - } else { - // Если еще не принял условия, переходим на экран с условиями - navigate('/onboarding/terms'); + + // Переходим на главный экран + // Пользователь уже принял условия на экране How-to + navigate('/'); + }; + + // Функция для получения цвета карточки по ID + const getCardColor = (id: string) => { + switch (id) { + case 'basic': + return '#FF6B9E'; // розовый + case 'optimal': + return '#673AB7'; // фиолетовый + case 'advanced': + return '#2196F3'; // синий + case 'super': + return '#C62828'; // красный + case 'unlimited': + return '#1B5E20'; // темно-зеленый (премиальный) + default: + return undefined; } }; + // Функция для определения, должна ли карточка быть широкой + const isFullWidth = (id: string) => { + return id === 'super' || id === 'unlimited'; + }; + return ( -
-
-
1
-
-

{getTranslation('select_stickers_title')}

-

{getTranslation('select_stickers_description')}

-
+ {/* Кнопка "Пропустить" в правом верхнем углу */} + + {/* Фиксированный хедер */} +
+

Пакеты токенов

+ {/* Переключатель цен */} +
+ +
+
-
-
2
-
-

{getTranslation('organize_pack_title')}

-

{getTranslation('organize_pack_description')}

+
+ {/* Левая колонка */} +
+ {/* Стартовый набор (первая карточка) */} + handleBuyPack(tokenPacks[0])} + /> + + {/* Стикерный запас (третья карточка) */} +
+ handleBuyPack(tokenPacks[2])} + />
- -
-
3
-
-

{getTranslation('publish_title')}

-

{getTranslation('publish_description')}

+ + {/* Правая колонка */} +
+ {/* Стикерный энтузиаст (вторая карточка) */} + handleBuyPack(tokenPacks[1])} + /> + + {/* Зеленая карточка с изображением (четвертая карточка) */} +
+ Эмоция
+ + {/* Стикерный магнат (пятая карточка, на всю ширину) */} + handleBuyPack(tokenPacks[3])} + /> + + {/* Бог стикеров (шестая карточка, на всю ширину) */} + handleBuyPack(tokenPacks[4])} + />
); diff --git a/src/screens/onboarding/OnboardingWelcome.module.css b/src/screens/onboarding/OnboardingWelcome.module.css new file mode 100644 index 0000000..3cb1924 --- /dev/null +++ b/src/screens/onboarding/OnboardingWelcome.module.css @@ -0,0 +1,203 @@ +.root { + display: flex; + flex-direction: column; + min-height: 100vh; + background: #fff; + box-sizing: border-box; + overflow-x: hidden; +} + +.imageBlock { + width: 100%; + max-width: 100%; + margin: 0; + padding: 0; + overflow: visible; + position: relative; + z-index: 1; + display: flex; + flex-direction: column; + align-items: stretch; +} + +.image { + width: 100%; + min-width: 100%; + max-width: 100%; + height: auto; + display: block; + margin: 0; + padding: 0; + border-radius: 0; + box-shadow: none; + object-fit: cover; + flex-shrink: 0; + flex-grow: 0; + /* Сдвиг вверх для маленьких экранов будет через медиазапросы */ +} + +.contentWrapper { + display: flex; + flex-direction: column; + flex: 1 1 auto; + width: 100%; + padding: 0 16px; + box-sizing: border-box; + justify-content: flex-start; +} + +.textContent { + margin: 0; + padding: 0 0 16px 0; +} + +.title { + font-size: 28px; + font-weight: 800; + color: #111; + text-align: center; + margin-bottom: 18px; + margin-top: 0; + line-height: 1.18; + letter-spacing: -0.5px; +} + +.description { + font-size: 18px; + color: #444; + text-align: center; + margin-bottom: 0; + margin-top: 0; + line-height: 1.5; + font-weight: 400; +} + +.bottomContent { + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + margin-top: auto; + margin-bottom: 0; + padding-top: 24px; +} + +.progress { + display: flex; + justify-content: center; + align-items: center; + gap: 8px; + margin-bottom: 16px; + width: 100%; +} + +.dot { + width: 32px; + height: 8px; + border-radius: 4px; + background: #e0e0e0; + transition: background 0.2s; +} + +.dotActive { + background: #16b100; +} + +.button { + width: calc(100% - 32px); + max-width: 370px; + background: #16b100; + color: #fff; + font-size: 20px; + font-weight: 700; + border: none; + border-radius: 12px; + padding: 18px 0; + margin: 0 0 32px 0; + cursor: pointer; + box-shadow: 0 2px 8px rgba(22, 177, 0, 0.08); + transition: background 0.2s; +} + +.button:active { + background: #129200; +} + +/* --- Медиазапросы для адаптивности --- */ +@media (max-width: 480px) { + .imageBlock { + max-height: none; + min-height: 120px; + } + .image { + max-height: none; + margin-top: -5vh; + } + .contentWrapper { + justify-content: center; + flex: 1 1 0%; + min-height: 0; + } + .bottomContent { + margin-top: 0; + margin-bottom: 0; + } + .title { + font-size: 22px; + } + .description { + font-size: 15px; + } + .button { + font-size: 17px; + padding: 14px 0; + } +} + +/* iPad и планшеты */ +@media (min-width: 600px) and (max-width: 1100px) { + .imageBlock { + min-height: 320px; + } + .image { + margin-top: -25vh; + } + .contentWrapper { + padding-top: 16px; + } +} + +@media (max-height: 667px) { + .imageBlock { + max-height: none; + min-height: 100px; + } + .image { + max-height: none; + margin-top: -10vh; + } + .title { + font-size: 20px; + margin-bottom: 12px; + } + .description { + font-size: 14px; + } +} + +@media (max-height: 568px) { + .imageBlock { + max-height: none; + min-height: 80px; + } + .image { + max-height: none; + margin-top: -12vh; + } + .textContent { + padding-bottom: 8px; + } + .bottomContent { + margin-top: 8px; + } +} diff --git a/src/screens/onboarding/OnboardingWelcome.tsx b/src/screens/onboarding/OnboardingWelcome.tsx index 992c24e..906ac10 100644 --- a/src/screens/onboarding/OnboardingWelcome.tsx +++ b/src/screens/onboarding/OnboardingWelcome.tsx @@ -1,16 +1,15 @@ import React, { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import OnboardingLayout from '../../components/shared/OnboardingLayout'; import { images } from '../../assets'; import customAnalyticsService from '../../services/customAnalyticsService'; import { getCurrentUserId } from '../../constants/user'; import { getTranslation } from '../../constants/translations'; +import styles from './OnboardingWelcome.module.css'; const OnboardingWelcome: React.FC = () => { const navigate = useNavigate(); useEffect(() => { - // Отслеживаем событие открытия экрана приветствия customAnalyticsService.trackEvent({ telegram_id: getCurrentUserId(), event_category: 'navigation', @@ -19,40 +18,47 @@ const OnboardingWelcome: React.FC = () => { }, []); const handleNext = () => { - // Отслеживаем событие нажатия кнопки "Далее" customAnalyticsService.trackEvent({ telegram_id: getCurrentUserId(), event_category: 'onboarding', event_name: 'welcome_next_click' }); - navigate('/onboarding/how-to'); }; - const handleSkip = () => { - // Отслеживаем событие нажатия кнопки "Пропустить" - customAnalyticsService.trackEvent({ - telegram_id: getCurrentUserId(), - event_category: 'onboarding', - event_name: 'welcome_skip_click' - }); - - localStorage.setItem('hasSeenOnboarding', 'true'); - navigate('/'); - }; - return ( - +
+
+ Онбординг +
+
+
+

+ Добро пожаловать
в Sticker Generator +

+
+ Создавайте уникальные стикеры
+ из фотографий с помощью
+ искусственного интеллекта +
+
+
+
+ + + +
+ +
+
+
); }; diff --git a/src/services/paymentService.ts b/src/services/paymentService.ts index 89f6b9e..c2a7b12 100644 --- a/src/services/paymentService.ts +++ b/src/services/paymentService.ts @@ -3,6 +3,15 @@ import apiService from '../services/api'; import { getCurrentUserId } from '../constants/user'; import customAnalyticsService from './customAnalyticsService'; +// Словарь для маппинга названий пакетов к значениям description для API +const packTitleToDescription: Record = { + 'Стартовый набор': 'стартовый набор', + 'Стикерный энтузиаст': 'стикерный энтузиаст', + 'Стикерный запас': 'стикерный запас', + 'Стикерный магнат': 'стикерный магнат', + 'Бог стикеров': 'бог стикеров' +}; + export const paymentService = { showBuyTokensPopup: async (pack: TokenPack, onSuccess?: (userData?: any) => void) => { // Проверяем наличие Telegram WebApp @@ -66,5 +75,76 @@ export const paymentService = { console.error('Ошибка при создании инвойса:', error); webApp.showAlert('Произошла ошибка при создании платежа. Пожалуйста, попробуйте позже.'); } + }, + + // Метод для оплаты рублями + showRubPaymentPopup: async (packTitle: string, onSuccess?: () => void) => { + // Проверяем наличие Telegram WebApp + if (!window.Telegram?.WebApp) { + console.error('Telegram WebApp не доступен'); + return; + } + + const webApp = window.Telegram.WebApp; + const userId = getCurrentUserId(); + + try { + // Получаем правильное описание пакета для API + const description = packTitleToDescription[packTitle] || packTitle.toLowerCase(); + + // Подготавливаем данные для запроса + const requestData = { + user_id: userId, + description: description + }; + + // Логируем данные запроса + console.log('Отправка запроса на создание платежа:'); + console.log('URL:', 'https://stickerserver.gymnasticstuff.uk/create-payment'); + console.log('Метод:', 'POST'); + console.log('Заголовки:', { 'Content-Type': 'application/json' }); + console.log('Тело запроса:', JSON.stringify(requestData, null, 2)); + + // Запрос к API для получения платежной ссылки + const response = await fetch('https://stickerserver.gymnasticstuff.uk/create-payment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestData) + }); + + if (!response.ok) { + throw new Error('Failed to create payment'); + } + + const data = await response.json(); + + // Логируем ответ сервера + console.log('Ответ сервера:', { + status: response.status, + statusText: response.statusText, + data: data + }); + + const paymentUrl = data.payment_url; + + // Отслеживаем событие начала оплаты + customAnalyticsService.trackEvent({ + telegram_id: userId, + event_category: 'payment', + event_name: 'rub_payment_start', + unit: packTitle + }); + + // Открываем ссылку в браузере Telegram + webApp.openLink(paymentUrl); + + // Вызываем callback успеха, если он предоставлен + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error('Ошибка при создании платежа:', error); + webApp.showAlert('Произошла ошибка при создании платежа. Пожалуйста, попробуйте позже.'); + } } }; diff --git a/vite.config.ts b/vite.config.ts index b689d5b..f1d69fa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,11 @@ import react from '@vitejs/plugin-react'; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + server: { + host: '0.0.0.0', // Делаем сервер доступным по локальной сети + port: 5173, // Фиксируем порт + strictPort: true, // Не пытаемся использовать другой порт, если 5173 занят + }, css: { modules: { localsConvention: 'camelCase', diff --git a/Редактор_стикеров_план_реализации.md b/Редактор_стикеров_план_реализации.md new file mode 100644 index 0000000..c8411aa --- /dev/null +++ b/Редактор_стикеров_план_реализации.md @@ -0,0 +1,91 @@ +# План внедрения редактора стикеров для Telegram Mini App + +## 1. Кнопка "Редактировать" и роутинг + +- Добавить кнопку/иконку "Редактировать" на каждый стикер в галерее. +- При нажатии — переход на экран `/edit/:stickerId`, где stickerId — идентификатор стикера (или file_id). +- На экран редактора передаётся webp-стикер 512x512 с прозрачным фоном. + +--- + +## 2. Экран редактора (MVP) + +- **Технология:** React + [react-konva](https://konvajs.org/docs/react/index.html) (canvas-редактирование, поддержка мобильных жестов, drag-n-drop, масштабирование, поворот). +- **Холст:** фиксированный размер 512x512, webp-стикер как прозрачный фон. +- **Инструменты:** + - Масштабирование и перемещение самого стикера (фонового слоя). + - Добавление текста (выбор шрифта из выпадающего списка, цвета из палитры, размер, позиция, поворот, обводка). + - Добавление PNG/эмодзи (drag-n-drop, resize, rotate, перемещение). + - Удаление объектов. +- **История изменений:** стек undo/redo (реализуется через массив состояний редактора, можно использовать useReducer или отдельную библиотеку типа [use-undo](https://github.com/aaronpowell/react-use-undo)). +- **UI:** крупные кнопки, панель инструментов снизу/сбоку, адаптация под мобильные (touch-жесты). + +--- + +## 3. Сохранение результата + +- Экспорт canvas в webp 512x512 (react-konva поддерживает toDataURL с нужным форматом и размером). +- Модальное окно подтверждения сохранения (в стиле NotificationModal, как в проекте). +- Отправка изображения на сервер через ваш эндпоинт (с телеграм id). +- После успешного сохранения — возврат в галерею (или показ уведомления). + +--- + +## 4. Расширяемость и удобство + +- **Шрифты:** + - Список шрифтов хранить в конфиге (например, src/config/editorFonts.ts). + - Добавлять новые шрифты — просто дописывать в конфиг и подключать через Google Fonts. +- **Палитра цветов:** + - Использовать готовый компонент color picker (например, [react-colorful](https://omgovich.github.io/react-colorful/)), палитру можно расширять. +- **Набор эмодзи/картинок:** + - Хранить в src/assets/editor-stickers/ (SVG/PNG/WebP). + - Добавлять новые — просто копировать в папку и прописывать в конфиге. +- **Предупреждение о несохранённых изменениях:** + - При попытке выхода из редактора без сохранения — показывать модальное окно с подтверждением. + +--- + +## UX-детали + +- Пользователь всегда видит результат редактирования в реальном времени. +- Все действия (масштабирование, перемещение, поворот) доступны для любого объекта, включая сам стикер. +- История изменений (undo/redo) работает в рамках одной сессии редактирования. +- Сохранение всегда в формате webp 512x512 с прозрачным фоном. +- Мобильная адаптация — приоритет (крупные кнопки, поддержка touch-жестов). + +--- + +## Сложность и сроки + +- MVP (редактирование, undo/redo, сохранение, мобильная адаптация): 2-3 недели. +- Добавление новых шрифтов/эмодзи — не требует изменений в коде, только в конфиге/папке. +- UI/UX — минимальные риски для остального приложения, редактор полностью изолирован. + +--- + +## Вопросы и ответы + +- **Ограничение на количество объектов:** не требуется. +- **Шрифты:** список в конфиге, легко расширять. +- **Цвета:** палитра, легко расширять. +- **Предпросмотр:** не нужен, пользователь видит результат сразу. +- **Модальное окно подтверждения:** обязательно, в стиле NotificationModal. +- **Набор эмодзи/картинок:** система через папку и конфиг, легко расширять. +- **Предупреждение о несохранённых изменениях:** обязательно. + +--- + +## Примерная структура компонентов + +- GallerySticker (с кнопкой "Редактировать") +- StickerEditorScreen (отдельный экран) + - EditorCanvas (react-konva) + - Toolbar (инструменты: текст, эмодзи, undo/redo, сохранить) + - ColorPicker, FontPicker, EmojiPicker (модальные/панельные компоненты) + - ConfirmModal (подтверждение сохранения/выхода) + +--- + +**Резюме:** +Редактор внедряется как отдельный экран, не затрагивает остальной функционал. Использование react-konva обеспечивает мобильную совместимость и нужный функционал. Все ресурсы (шрифты, эмодзи) легко расширяемы через конфиг/папку. UX — максимально близок к мобильным привычкам.