From 6c0fa8466081022d7d64dec97cffdfcb47091efe Mon Sep 17 00:00:00 2001 From: kazachilo Date: Wed, 26 Mar 2025 16:45:28 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B4=D0=BE=D1=80=D0=B0=D0=B1=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D1=8B=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=D1=8B?= =?UTF-8?q?=20=D0=BE=D0=BF=D0=BB=D0=B0=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/layout/Header.tsx | 71 +++++-- src/components/layout/Layout.module.css | 10 +- .../tokens/TokenPackCard.module.css | 182 ++++++++++++++++++ src/components/tokens/TokenPackCard.tsx | 75 ++++++++ .../tokens/TokenPacksList.module.css | 111 +++++++++++ src/components/tokens/TokenPacksList.tsx | 51 +++++ .../tokens/TokenPacksModal.module.css | 123 ++++++++++++ src/components/tokens/TokenPacksModal.tsx | 70 +++++++ src/constants/mock.ts | 2 +- src/constants/tokenPacks.ts | 69 +++++++ src/screens/CreateSticker.module.css | 52 +++-- src/screens/CreateSticker.tsx | 60 +++++- src/screens/Home.tsx | 42 +++- src/screens/Profile.module.css | 56 +++--- src/screens/Profile.tsx | 57 +++--- src/services/api.ts | 29 +++ src/services/paymentService.ts | 39 ++++ src/types/telegram-webapp.d.ts | 14 ++ 18 files changed, 1022 insertions(+), 91 deletions(-) create mode 100644 src/components/tokens/TokenPackCard.module.css create mode 100644 src/components/tokens/TokenPackCard.tsx create mode 100644 src/components/tokens/TokenPacksList.module.css create mode 100644 src/components/tokens/TokenPacksList.tsx create mode 100644 src/components/tokens/TokenPacksModal.module.css create mode 100644 src/components/tokens/TokenPacksModal.tsx create mode 100644 src/constants/tokenPacks.ts create mode 100644 src/services/paymentService.ts diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index baaf734..6d97736 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -1,26 +1,55 @@ import React, { useState, useEffect } from 'react'; -import { Link } from 'react-router-dom'; -import { MOCK_USER } from '../../constants/mock'; -import { getUserInfo, isTelegramWebAppAvailable } from '../../constants/user'; +import { Link, useNavigate } from 'react-router-dom'; +import { getUserInfo, isTelegramWebAppAvailable, getCurrentUserId } from '../../constants/user'; import { images } from '../../assets'; +import apiService from '../../services/api'; +import TokenPacksModal from '../tokens/TokenPacksModal'; +import { paymentService } from '../../services/paymentService'; +import { tokenPacks } from '../../constants/tokenPacks'; import styles from './Header.module.css'; const Header: React.FC = () => { - const [user, setUser] = useState(MOCK_USER); + const navigate = useNavigate(); + const [user, setUser] = useState({ + telegramId: 0, + username: '', + avatarUrl: '', + balance: 0 + }); + const [showTokensModal, setShowTokensModal] = useState(false); useEffect(() => { // Получаем информацию о пользователе const userInfo = getUserInfo(); - // Если есть данные из Telegram, обновляем состояние - if (isTelegramWebAppAvailable()) { - setUser({ - telegramId: userInfo.id, - username: userInfo.first_name + (userInfo.last_name ? ` ${userInfo.last_name}` : ''), - avatarUrl: userInfo.photo_url || MOCK_USER.avatarUrl, // Используем фото из Telegram или дефолтное - balance: MOCK_USER.balance // Баланс оставляем из моковых данных - }); - } + const fetchData = async () => { + try { + // Получаем баланс пользователя + const balance = await apiService.getBalance(getCurrentUserId()); + + // Если есть данные из Telegram, обновляем состояние + if (isTelegramWebAppAvailable()) { + setUser({ + telegramId: userInfo.id, + username: userInfo.first_name + (userInfo.last_name ? ` ${userInfo.last_name}` : ''), + avatarUrl: userInfo.photo_url || '/ava.jpg', // Используем фото из Telegram или дефолтное + balance: balance + }); + } else { + // Для локальной разработки + setUser({ + telegramId: 12345678, + username: "TestUser", + avatarUrl: "/ava.jpg", + balance: balance + }); + } + } catch (error) { + console.error('Error fetching user data:', error); + } + }; + + fetchData(); }, []); return ( @@ -48,7 +77,7 @@ const Header: React.FC = () => { {/* Баланс токенов */} + + {/* Модальное окно с пакетами токенов */} + setShowTokensModal(false)} + onShowAllPacks={() => navigate('/profile')} + missingTokens={0} + onBuyPack={(packId) => { + setShowTokensModal(false); + paymentService.showBuyTokensPopup(tokenPacks.find(p => p.id === packId)!, () => { + window.location.reload(); + }); + }} + /> ); }; diff --git a/src/components/layout/Layout.module.css b/src/components/layout/Layout.module.css index 113b85f..1e33ef2 100644 --- a/src/components/layout/Layout.module.css +++ b/src/components/layout/Layout.module.css @@ -1,14 +1,14 @@ .layout { min-height: 100vh; background-color: var(--color-background); - padding-bottom: 4rem; /* Отступ для навигации */ + padding-bottom: 6rem; /* Увеличенный отступ для навигации */ display: flex; flex-direction: column; width: 100%; overflow: hidden; - position: relative; /* Добавляем относительное позиционирование */ - height: 100%; /* Устанавливаем высоту 100% */ - overscroll-behavior: none; /* Предотвращает "резиновый" эффект на iOS */ + position: relative; + height: 100%; + overscroll-behavior: none; } .main { @@ -20,7 +20,7 @@ -webkit-overflow-scrolling: touch; /* Улучшаем инерционный скроллинг на iOS */ padding-top: 0px; /* Уменьшаем отступ до 20px */ overscroll-behavior: contain; /* Ограничивает эффект скролла только этим элементом */ - height: calc(100% - 4rem); /* Вычитаем высоту навигации */ + height: calc(100% - 6rem); /* Вычитаем увеличенную высоту навигации */ } .container { diff --git a/src/components/tokens/TokenPackCard.module.css b/src/components/tokens/TokenPackCard.module.css new file mode 100644 index 0000000..372e8ab --- /dev/null +++ b/src/components/tokens/TokenPackCard.module.css @@ -0,0 +1,182 @@ +.card { + border-radius: 12px; + padding: 12px; + background-color: var(--color-surface); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + position: relative; + transition: transform 0.2s, box-shadow 0.2s; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.popular { + border: 2px solid var(--color-primary); +} + +.bestValue { + border: 2px solid var(--color-gold, #FFD700); +} + +.badge { + position: absolute; + top: -10px; + right: 10px; + padding: 4px 8px; + border-radius: 12px; + font-size: 12px; + font-weight: bold; + color: white; +} + +.popularBadge { + background-color: var(--color-primary); +} + +.bestValueBadge { + background-color: var(--color-gold, #FFD700); + color: #333; +} + +.title { + margin: 0 0 8px 0; + font-size: 15px; + font-weight: bold; +} + +.tokenInfo { + display: flex; + align-items: center; + margin-bottom: 6px; +} + +.tokenIcon { + width: 24px; + height: 24px; + margin-right: 8px; +} + +.tokenCount { + display: flex; + align-items: center; + gap: 4px; +} + +.baseTokens { + font-size: 18px; + font-weight: bold; +} + +.bonusTokens { + color: var(--color-success, #4CAF50); + font-weight: bold; +} + +.stickersCount { + font-size: 14px; + color: var(--color-text-secondary); + margin-bottom: 12px; +} + +.description { + font-size: 13px; + margin-bottom: 12px; + color: var(--color-text); + opacity: 0.8; +} + +.priceSection { + display: flex; + justify-content: space-between; + align-items: center; +} + +.price { + font-weight: bold; +} + +.buyButton { + background-color: var(--color-primary); + color: white; + border: none; + border-radius: 4px; + padding: 8px 16px; + font-weight: bold; + cursor: pointer; + transition: background-color 0.2s; +} + + +.card.compact { + padding: 8px; + width: 150px; /* Adjust as needed */ + height: 250px; /* Adjust as needed */ + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; /* Align items to the top */ +} + +.card.compact .title { + font-size: 14px; + margin-bottom: 4px; + text-align: center; /* Center the title */ +} + +.card.compact .tokenInfo { + margin-bottom: 4px; + justify-content: center; /* Center the token info */ + flex-direction: column; + align-items: center; +} + +.card.compact .tokenIcon { + width: 20px; + height: 20px; + margin-right: 0; +} + +.card.compact .tokenCount { + flex-direction: column; /* Stack tokens and bonus tokens */ + align-items: center; /* Center the items */ +} + +.card.compact .baseTokens { + font-size: 16px; +} + +.card.compact .bonusTokens { + font-size: 12px; +} + +.card.compact .stickersCount { + font-size: 12px; + margin-bottom: 8px; + text-align: center; /* Center the stickers count */ +} + +.card.compact .description { + font-size: 12px; + margin-bottom: 8px; + text-align: center; /* Center the description */ +} + +.card.compact .priceSection { + flex-direction: column; + align-items: center; /* Center the price section */ + margin-top: auto; /* Push price section to the bottom */ +} + +.card.compact .price { + font-size: 14px; + margin-bottom: 4px; + text-align: center; /* Center the price */ +} + +.card.compact .buyButton { + padding: 6px 12px; + font-size: 12px; + width: 100%; /* Make the button full width */ +} diff --git a/src/components/tokens/TokenPackCard.tsx b/src/components/tokens/TokenPackCard.tsx new file mode 100644 index 0000000..ab7375f --- /dev/null +++ b/src/components/tokens/TokenPackCard.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { images } from '../../assets'; +import { TokenPack } from '../../constants/tokenPacks'; +import styles from './TokenPackCard.module.css'; + +interface TokenPackCardProps extends TokenPack { + onBuy: () => void; + className?: string; + compact?: boolean; +} + +const TokenPackCard: React.FC = ({ + title, + tokens, + bonusTokens, + stickersCount, + price, + priceRub, + description, + isPopular, + isBestValue, + onBuy, + className = '' +}) => { + return ( +
+ {isPopular && ( +
+ Популярный выбор +
+ )} + {isBestValue && ( +
+ Максимальная выгода +
+ )} + +

{title}

+ +
+ Токены +
+ {tokens} + {bonusTokens > 0 && ( + +{bonusTokens} БОНУС + )} +
+
+ +
+ {stickersCount} стикеров +
+ + {description && ( +

{description}

+ )} + +
+
+ {price} Stars ({priceRub} ₽) +
+ +
+
+ ); +}; + +export default TokenPackCard; diff --git a/src/components/tokens/TokenPacksList.module.css b/src/components/tokens/TokenPacksList.module.css new file mode 100644 index 0000000..93c71a0 --- /dev/null +++ b/src/components/tokens/TokenPacksList.module.css @@ -0,0 +1,111 @@ +.container { + padding: 0; + margin-top: 8px; +} + +.horizontalScroll { + display: flex; + overflow-x: auto; + padding: 8px 0; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x mandatory; + gap: 16px; + margin: 0 -8px; + padding: 8px; +} + +.horizontalScroll > * { + flex-shrink: 0; + width: 280px; + scroll-snap-align: start; +} + +/* Стилизация скроллбара */ +.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); +} + +.title { + font-size: 18px; + font-weight: bold; + margin: 0 0 12px 0; + color: var(--color-text); + text-align: center; +} + +.list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.horizontalScroll { + display: flex; + overflow-x: auto; + padding: 8px 0; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x mandatory; + gap: 16px; + margin: 0 -8px; + padding: 8px; + flex-direction: row; /* Override column direction */ + scroll-margin-left: 150px; /* Adjust as needed */ +} + +.horizontalScroll > *:first-child { + margin-left: -150px; /* Adjust as needed */ +} + +.list.compact { + max-height: 300px; + overflow-y: auto; +} + +.compactList { + max-height: 300px; + overflow-y: auto; +} + +/* Убираем margin у карточек внутри списка, так как используем gap */ +.list > * { + margin: 0 !important; +} + +/* Адаптивный дизайн для больших экранов */ +@media (min-width: 768px) { + .list:not(.horizontalScroll) { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 24px; + } +} + +/* Анимация появления */ +.list > * { + animation: fadeIn 0.3s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/src/components/tokens/TokenPacksList.tsx b/src/components/tokens/TokenPacksList.tsx new file mode 100644 index 0000000..688cbec --- /dev/null +++ b/src/components/tokens/TokenPacksList.tsx @@ -0,0 +1,51 @@ +import React, { useRef, useEffect } from 'react'; +import { tokenPacks } from '../../constants/tokenPacks'; +import TokenPackCard from './TokenPackCard'; +import styles from './TokenPacksList.module.css'; + +interface TokenPacksListProps { + onBuyPack: (packId: string) => void; + className?: string; + compact?: boolean; +} + +const TokenPacksList: React.FC = ({ + onBuyPack, + className = '', + compact +}) => { + const listRef = useRef(null); + + useEffect(() => { + if (compact && listRef.current) { + const secondCard = listRef.current.children[1] as HTMLElement; + if (secondCard) { + listRef.current.scrollTo({ + left: secondCard.offsetLeft, + behavior: 'smooth', + }); + } + } + }, [compact]); + + return ( +
+

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

+
+ {tokenPacks.map(pack => ( + onBuyPack(pack.id)} + /> + ))} +
+
+ ); +}; + +export default TokenPacksList; diff --git a/src/components/tokens/TokenPacksModal.module.css b/src/components/tokens/TokenPacksModal.module.css new file mode 100644 index 0000000..93181d5 --- /dev/null +++ b/src/components/tokens/TokenPacksModal.module.css @@ -0,0 +1,123 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal { + background-color: var(--color-background); + border-radius: var(--border-radius); + padding: var(--spacing-medium); + width: calc(100% - var(--spacing-medium) * 2); + max-width: 400px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + margin: var(--spacing-medium); + max-height: 80vh; + overflow-y: auto; + position: relative; +} + +@supports (-webkit-touch-callout: none) { + /* Стили только для iOS устройств */ + .modal { + width: calc(100% - var(--spacing-large) * 2); + margin: var(--spacing-large); + max-width: 90vw; /* Ограничиваем ширину относительно вьюпорта */ + } +} + +.title { + margin: 16px 0 8px 0; + font-size: 18px; + text-align: center; +} + +.subtitle { + margin: 0 0 16px 0; + font-size: 14px; + text-align: center; + color: var(--color-text-secondary); +} + +.horizontalScroll { + display: flex; + overflow-x: auto; + padding: 8px 0; + -webkit-overflow-scrolling: touch; + scroll-snap-type: x mandatory; + gap: 16px; + margin: 0 -8px; + padding: 8px; +} + +.horizontalScroll > * { + flex-shrink: 0; + width: 280px; + scroll-snap-align: start; +} + +.showAllButton { + display: block; + margin: 16px auto 0; + background-color: transparent; + border: 1px solid var(--color-primary); + color: var(--color-primary); + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-weight: bold; + transition: all 0.2s; +} + +.showAllButton:hover { + background-color: var(--color-primary); + color: white; +} + +.closeButton { + position: absolute; + top: 12px; + right: 12px; + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: var(--color-text-secondary); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: background-color 0.2s; +} + +.closeButton:hover { + 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); +} diff --git a/src/components/tokens/TokenPacksModal.tsx b/src/components/tokens/TokenPacksModal.tsx new file mode 100644 index 0000000..1dd09c2 --- /dev/null +++ b/src/components/tokens/TokenPacksModal.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { tokenPacks, TokenPack } from '../../constants/tokenPacks'; +import styles from './TokenPacksModal.module.css'; +import TokenPacksList from './TokenPacksList'; + +interface TokenPacksModalProps { + isVisible: boolean; + onClose: () => void; + onShowAllPacks: () => void; + missingTokens: number; + onBuyPack: (packId: string) => void; +} + +const TokenPacksModal: React.FC = ({ + isVisible, + onClose, + onShowAllPacks, + missingTokens, + onBuyPack +}) => { + if (!isVisible) return null; + + // Находим минимальный пакет, который покроет недостающие токены + const recommendedPack = tokenPacks.find(pack => + (pack.tokens + pack.bonusTokens) >= missingTokens + ) || tokenPacks[0]; + + // Отображаем рекомендованный пакет и следующие два + const startIndex = Math.max( + 0, + tokenPacks.findIndex(pack => pack.id === recommendedPack.id) + ); + const displayPacks = tokenPacks.slice(startIndex, startIndex + 3); + + return ( +
+
e.stopPropagation()}> +

Недостаточно токенов для генерации

+ + + + + + +
+
+ ); +}; + +export default TokenPacksModal; diff --git a/src/constants/mock.ts b/src/constants/mock.ts index 466b414..97c4c0d 100644 --- a/src/constants/mock.ts +++ b/src/constants/mock.ts @@ -4,7 +4,7 @@ export const MOCK_USER: MockUser = { telegramId: 12345678, username: "TestUser", avatarUrl: "/ava.jpg", - balance: 1000 + balance: -5 }; export const MOCK_STYLES: StyleOption[] = [ diff --git a/src/constants/tokenPacks.ts b/src/constants/tokenPacks.ts new file mode 100644 index 0000000..7025692 --- /dev/null +++ b/src/constants/tokenPacks.ts @@ -0,0 +1,69 @@ +import { images } from '../assets'; + +export interface TokenPack { + id: string; + title: string; + tokens: number; + bonusTokens: number; + stickersCount: number; + price: number; + priceRub: number; + description: string; + isPopular?: boolean; + isBestValue?: boolean; +} + +export const tokenPacks: TokenPack[] = [ + { + id: 'basic', + title: 'Стартовый набор стикеромана', + tokens: 150, + bonusTokens: 0, + stickersCount: 15, + price: 100, + priceRub: 179, + description: 'Идеальный вариант для начала! Сгенерируйте 15 уникальных стикеров для вашего Telegram.' + }, + { + id: 'optimal', + title: 'Стикерный запас', + tokens: 250, + bonusTokens: 30, + stickersCount: 28, + price: 150, + priceRub: 259, + description: 'Создавайте стикеры для всех случаев жизни с оптимальным запасом токенов.', + isPopular: true + }, + { + id: 'advanced', + title: 'Стикерный энтузиаст', + tokens: 500, + bonusTokens: 75, + stickersCount: 57, + price: 350, + priceRub: 589, + description: 'Расширьте свои возможности! Создавайте стикеры без ограничений.' + }, + { + id: 'super', + title: 'Стикерный магнат', + tokens: 750, + bonusTokens: 150, + stickersCount: 90, + price: 500, + priceRub: 839, + description: 'Для самых требовательных. Создавайте целые коллекции стикеров с максимальной выгодой.' + }, + { + id: 'unlimited', + title: 'Бог стикеров', + tokens: 1500, + bonusTokens: 450, + stickersCount: 195, + price: 1000, + priceRub: 1659, + description: 'Для профессионалов и настоящих ценителей. Неограниченные возможности для творчества с максимальной выгодой.', + isBestValue: true + } +]; diff --git a/src/screens/CreateSticker.module.css b/src/screens/CreateSticker.module.css index 9da760c..6233e3a 100644 --- a/src/screens/CreateSticker.module.css +++ b/src/screens/CreateSticker.module.css @@ -2,60 +2,84 @@ display: flex; flex-direction: column; gap: var(--spacing-large); + padding: calc(3rem + var(--spacing-small)) var(--spacing-medium) var(--spacing-large); + width: 100%; + box-sizing: border-box; } .header { - padding: var(--spacing-medium) 0; + padding: var(--spacing-small) 0; + text-align: center; + margin-bottom: 0; } .title { font-size: 1.5rem; font-weight: bold; color: var(--color-text); + display: inline-block; } .subtitle { margin-top: 0.25rem; color: var(--color-text); opacity: 0.6; + text-align: center; } .uploadArea { + display: flex; + justify-content: center; + align-items: center; padding: var(--spacing-medium); - background-color: var(--color-surface); - border-radius: var(--border-radius); + cursor: pointer; } .uploadBox { - padding: var(--spacing-large); - border: 2px dashed var(--color-border); - border-radius: var(--border-radius); display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: var(--spacing-small); - cursor: pointer; - transition: all 0.2s; + padding: var(--spacing-large); + border: 2px dashed var(--color-primary); + border-radius: var(--border-radius); + background-color: var(--color-surface); + width: 100%; + max-width: 400px; + transition: all 0.2s ease; } .uploadBox:hover { - border-color: var(--color-primary); - background-color: var(--color-background); + background-color: var(--color-surface-variant); + border-color: var(--color-primary-dark); } .uploadIcon { - font-size: 3rem; + font-size: 48px; margin-bottom: var(--spacing-small); } .uploadText { - font-size: 1.125rem; - font-weight: 500; + font-size: 1.1rem; + font-weight: bold; color: var(--color-text); } .uploadHint { - font-size: 0.875rem; + font-size: 0.9rem; color: var(--color-text); opacity: 0.6; } + +.tokenCost { + margin-top: var(--spacing-medium); + padding: var(--spacing-small) var(--spacing-medium); + background-color: var(--color-surface-variant); + border-radius: var(--border-radius); + font-size: 0.9rem; + color: var(--color-text); + display: flex; + align-items: center; + gap: var(--spacing-small); +} diff --git a/src/screens/CreateSticker.tsx b/src/screens/CreateSticker.tsx index 75ed86a..c0f3d71 100644 --- a/src/screens/CreateSticker.tsx +++ b/src/screens/CreateSticker.tsx @@ -1,7 +1,44 @@ -import React from 'react'; +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; import styles from './CreateSticker.module.css'; +import TokenPacksModal from '../components/tokens/TokenPacksModal'; +import { tokenPacks } from '../constants/tokenPacks'; +import { paymentService } from '../services/paymentService'; +import apiService from '../services/api'; +import { getCurrentUserId } from '../constants/user'; + +const TOKENS_PER_GENERATION = 10; const CreateSticker: React.FC = () => { + const navigate = useNavigate(); + const [showTokensModal, setShowTokensModal] = useState(false); + const [missingTokens, setMissingTokens] = useState(0); + + const checkTokensAndProceed = async () => { + try { + const userTokens = await apiService.getBalance(getCurrentUserId()); + if (userTokens < TOKENS_PER_GENERATION) { + setMissingTokens(TOKENS_PER_GENERATION - userTokens); + setShowTokensModal(true); + return false; + } + + return true; + } catch (error) { + console.error('Error checking balance:', error); + alert('Произошла ошибка при проверке баланса. Попробуйте позже.'); + return false; + } + }; + + const handleUploadClick = async () => { + const canProceed = await checkTokensAndProceed(); + if (canProceed) { + // Здесь будет логика загрузки фото + console.log('Загрузка фото...'); + } + }; + return (
@@ -13,7 +50,10 @@ const CreateSticker: React.FC = () => {

-
+
📷 @@ -22,8 +62,24 @@ const CreateSticker: React.FC = () => { или перетащите файл сюда + + Стоимость: {TOKENS_PER_GENERATION} токенов +
+ + setShowTokensModal(false)} + onShowAllPacks={() => navigate('/profile')} + missingTokens={missingTokens} + onBuyPack={(packId: string) => { + setShowTokensModal(false); + paymentService.showBuyTokensPopup(tokenPacks.find(p => p.id === packId)!, () => { + window.location.reload(); + }); + }} + />
); }; diff --git a/src/screens/Home.tsx b/src/screens/Home.tsx index d7fef58..dc05111 100644 --- a/src/screens/Home.tsx +++ b/src/screens/Home.tsx @@ -1,13 +1,16 @@ import React, { useState, useCallback, useEffect, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import BlockRenderer from '../components/blocks/BlockRenderer'; -// import UploadPhotoBlock from '../components/blocks/UploadPhotoBlock'; // Не используется import styles from './Home.module.css'; import { homeScreenConfig } from '../config/homeScreen'; import { stylePresets } from '../config/stylePresets'; import apiService from '../services/api'; import NotificationModal from '../components/shared/NotificationModal'; import FeedbackHandler, { FeedbackHandlerRef } from '../components/shared/FeedbackHandler'; +import TokenPacksModal from '../components/tokens/TokenPacksModal'; +import { paymentService } from '../services/paymentService'; +import { tokenPacks } from '../constants/tokenPacks'; +import { getCurrentUserId } from '../constants/user'; // Интерфейс для хранения данных о последней генерации interface LastGenerationData { @@ -45,11 +48,11 @@ const Home: React.FC = () => { const [isLoading, setIsLoading] = useState(false); const [promptText, setPromptText] = useState(''); const [showGalleryButton, setShowGalleryButton] = useState(true); - const [showButtons, setShowButtons] = useState(true); // Новое состояние для управления видимостью всех кнопок - const [continueButtonText, setContinueButtonText] = useState('Продолжить'); // Новое состояние для текста кнопки "Продолжить" - - // Состояние для хранения данных о последней успешной генерации + const [showButtons, setShowButtons] = useState(true); + const [continueButtonText, setContinueButtonText] = useState('Продолжить'); const [lastGenerationData, setLastGenerationData] = useState({}); + const [showTokensModal, setShowTokensModal] = useState(false); + const [missingTokens, setMissingTokens] = useState(0); // Обработчики для модального окна const handleGalleryClick = useCallback(() => { @@ -152,14 +155,24 @@ const Home: React.FC = () => { } try { + // Проверяем баланс перед генерацией + const userTokens = await apiService.getBalance(getCurrentUserId()); + const TOKENS_PER_GENERATION = 10; + + if (userTokens < TOKENS_PER_GENERATION) { + setMissingTokens(TOKENS_PER_GENERATION - userTokens); + setShowTokensModal(true); + return; + } + // Показываем уведомление о начале генерации setNotificationTitle('Генерация стикера'); setNotificationMessage('Отправка запроса...'); setIsLoading(true); setPromptText(''); - setShowGalleryButton(true); // Показываем кнопку "В галерею" для уведомлений о генерации - setShowButtons(false); // Скрываем все кнопки во время отправки запроса - setContinueButtonText('Продолжить'); // Сбрасываем текст кнопки на значение по умолчанию + setShowGalleryButton(true); + setShowButtons(false); + setContinueButtonText('Продолжить'); setIsNotificationVisible(true); // Если выбран "Свой промпт" и введен текст, используем его @@ -356,6 +369,19 @@ const Home: React.FC = () => { />
+ {/* Модальное окно с пакетами токенов */} + setShowTokensModal(false)} + onShowAllPacks={() => navigate('/profile')} + missingTokens={missingTokens} + onBuyPack={(packId: string) => { + setShowTokensModal(false); + paymentService.showBuyTokensPopup(tokenPacks.find(p => p.id === packId)!, () => { + window.location.reload(); + }); + }} + /> {/* Блоки из конфигурации */}
{homeScreenConfig.homeScreen.blocks diff --git a/src/screens/Profile.module.css b/src/screens/Profile.module.css index 0f8a2a2..e7fe8a6 100644 --- a/src/screens/Profile.module.css +++ b/src/screens/Profile.module.css @@ -1,63 +1,75 @@ .container { display: flex; flex-direction: column; - gap: var(--spacing-large); - padding: calc(3rem + var(--spacing-small)) var(--spacing-medium) var(--spacing-large); + gap: 12px; + padding: calc(3rem + var(--spacing-small)) var(--spacing-medium) 6rem; width: 100%; box-sizing: border-box; } .header { - padding: var(--spacing-small) 0; + display: flex; + flex-direction: column; + align-items: center; text-align: center; - margin-bottom: 0; + padding: 8px 0; } .title { font-size: 1.5rem; font-weight: bold; color: var(--color-text); - display: inline-block; + margin: 0; } .subtitle { - margin-top: 0.25rem; + margin: 0; color: var(--color-text); opacity: 0.6; - text-align: center; + font-size: 0.875rem; } -.card { - background-color: var(--color-surface); - border-radius: var(--border-radius); - padding: var(--spacing-medium); -} - -.stats { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: var(--spacing-medium); +.compactStats { + display: flex; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; } .statItem { + flex: 1; display: flex; flex-direction: column; align-items: center; text-align: center; - padding: var(--spacing-medium); - background-color: var(--color-background); + padding: 12px 8px; + background-color: var(--color-surface); border-radius: var(--border-radius); } .statValue { - font-size: 1.5rem; + font-size: 18px; font-weight: bold; color: var(--color-primary); } .statLabel { - margin-top: 0.25rem; + margin-top: 4px; color: var(--color-text); opacity: 0.6; - font-size: 0.875rem; + font-size: 0.75rem; +} + +.tokenPacks { + margin-top: 12px; +} + +@media (min-width: 768px) { + .compactStats { + gap: 12px; + } + + .statItem { + padding: 16px; + } } diff --git a/src/screens/Profile.tsx b/src/screens/Profile.tsx index 5565bcc..c6b76d8 100644 --- a/src/screens/Profile.tsx +++ b/src/screens/Profile.tsx @@ -1,9 +1,11 @@ import React, { useState, useEffect } from 'react'; import styles from './Profile.module.css'; -import { MOCK_USER } from '../constants/mock'; import { stickerService } from '../services/stickerService'; import apiService from '../services/api'; import { getCurrentUserId } from '../constants/user'; +import TokenPacksList from '../components/tokens/TokenPacksList'; +import { tokenPacks } from '../constants/tokenPacks'; +import { paymentService } from '../services/paymentService'; const Profile: React.FC = () => { const [stickersCount, setStickersCount] = useState(0); @@ -32,37 +34,42 @@ const Profile: React.FC = () => { fetchData(); }, []); + const handleBuyPack = (packId: string) => { + const pack = tokenPacks.find(p => p.id === packId); + if (!pack) return; + + paymentService.showBuyTokensPopup(pack, () => { + // Обновляем данные после успешной оплаты + window.location.reload(); + }); + }; + return (
-

- Профиль -

-

- Ваша статистика и настройки -

+

Профиль

+

Ваша статистика и настройки

-
-
-
- {MOCK_USER.balance} - Токенов -
-
- {loading ? '...' : stickersCount} - Стикеров создано -
-
- {loading ? '...' : packsCount} - Стикерпаков -
-
- 0 - Избранных -
+
+
+ {loading ? '...' : stickersCount} + Стикеров создано +
+
+ {loading ? '...' : packsCount} + Стикерпаков +
+
+ 0 + Избранных
+ +
); }; diff --git a/src/services/api.ts b/src/services/api.ts index b9b7ba1..6964680 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -48,7 +48,36 @@ class GenerationError extends Error { } } +// Временное решение для работы с балансом токенов +// В будущем будет заменено на API-запросы +let mockBalance = -5; // Начальное значение из MOCK_USER + const apiService = { + // Метод для списания токенов + async deductTokens(userId: number, amount: number): Promise { + try { + // В будущем здесь будет API-запрос для списания токенов + // Пока реализуем локальное списание + mockBalance -= amount; + return true; + } catch (error) { + console.error('Error deducting tokens:', error); + return false; + } + }, + + // Метод для получения текущего баланса + async getBalance(userId: number): Promise { + try { + // В будущем здесь будет API-запрос для получения баланса + // Пока возвращаем локальное значение + return mockBalance; + } catch (error) { + console.error('Error getting balance:', error); + throw error; + } + }, + // Получение списка задач пользователя в статусе PENDING async getUserPendingTasks(userId = getCurrentUserId()): Promise { try { diff --git a/src/services/paymentService.ts b/src/services/paymentService.ts new file mode 100644 index 0000000..7a98c99 --- /dev/null +++ b/src/services/paymentService.ts @@ -0,0 +1,39 @@ +import { TokenPack } from '../constants/tokenPacks'; + +export const paymentService = { + showBuyTokensPopup: (pack: TokenPack, onSuccess?: () => void) => { + // Проверяем наличие Telegram WebApp + if (!window.Telegram?.WebApp) { + console.error('Telegram WebApp не доступен'); + return; + } + + const webApp = window.Telegram.WebApp; + + // Открываем окно оплаты Telegram + webApp.showPopup({ + title: 'Покупка токенов', + message: `Вы собираетесь купить пакет "${pack.title}" за ${pack.price} Stars (${pack.priceRub} ₽)`, + buttons: [ + { + type: 'ok', + text: 'Купить', + id: 'buy' + }, + { + type: 'cancel', + text: 'Отмена' + } + ] + }, (buttonId: string) => { + if (buttonId === 'buy') { + // Открываем встроенный платеж Telegram + webApp.openInvoice(`sticker_tokens_${pack.id}`, (status: 'paid' | 'cancelled' | 'failed' | 'pending') => { + if (status === 'paid' && onSuccess) { + onSuccess(); + } + }); + } + }); + } +}; diff --git a/src/types/telegram-webapp.d.ts b/src/types/telegram-webapp.d.ts index e810716..8a96da3 100644 --- a/src/types/telegram-webapp.d.ts +++ b/src/types/telegram-webapp.d.ts @@ -15,6 +15,18 @@ interface TelegramWebAppInitData { hash?: string; } +interface PopupButton { + id?: string; + type: 'ok' | 'close' | 'cancel'; + text?: string; +} + +interface PopupParams { + title?: string; + message: string; + buttons?: PopupButton[]; +} + interface TelegramWebApp { initData: string; initDataUnsafe: TelegramWebAppInitData; @@ -40,6 +52,8 @@ interface TelegramWebApp { openTelegramLink(url: string): void; showAlert(message: string, callback?: () => void): void; showConfirm(message: string, callback?: (confirmed: boolean) => void): void; + showPopup(params: PopupParams, callback?: (buttonId: string) => void): void; + openInvoice(url: string, callback?: (status: 'paid' | 'cancelled' | 'failed' | 'pending') => void): void; MainButton: { text: string; color: string;