fix: доработаны экраны оплаты

This commit is contained in:
kazachilo 2025-03-26 16:45:28 +03:00
parent 527226b1fb
commit 6c0fa84660
18 changed files with 1022 additions and 91 deletions

View File

@ -1,26 +1,55 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { MOCK_USER } from '../../constants/mock'; import { getUserInfo, isTelegramWebAppAvailable, getCurrentUserId } from '../../constants/user';
import { getUserInfo, isTelegramWebAppAvailable } from '../../constants/user';
import { images } from '../../assets'; 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'; import styles from './Header.module.css';
const Header: React.FC = () => { 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(() => { useEffect(() => {
// Получаем информацию о пользователе // Получаем информацию о пользователе
const userInfo = getUserInfo(); const userInfo = getUserInfo();
const fetchData = async () => {
try {
// Получаем баланс пользователя
const balance = await apiService.getBalance(getCurrentUserId());
// Если есть данные из Telegram, обновляем состояние // Если есть данные из Telegram, обновляем состояние
if (isTelegramWebAppAvailable()) { if (isTelegramWebAppAvailable()) {
setUser({ setUser({
telegramId: userInfo.id, telegramId: userInfo.id,
username: userInfo.first_name + (userInfo.last_name ? ` ${userInfo.last_name}` : ''), username: userInfo.first_name + (userInfo.last_name ? ` ${userInfo.last_name}` : ''),
avatarUrl: userInfo.photo_url || MOCK_USER.avatarUrl, // Используем фото из Telegram или дефолтное avatarUrl: userInfo.photo_url || '/ava.jpg', // Используем фото из Telegram или дефолтное
balance: MOCK_USER.balance // Баланс оставляем из моковых данных balance: balance
});
} else {
// Для локальной разработки
setUser({
telegramId: 12345678,
username: "TestUser",
avatarUrl: "/ava.jpg",
balance: balance
}); });
} }
} catch (error) {
console.error('Error fetching user data:', error);
}
};
fetchData();
}, []); }, []);
return ( return (
@ -48,7 +77,7 @@ const Header: React.FC = () => {
{/* Баланс токенов */} {/* Баланс токенов */}
<button <button
className={styles.balance} className={styles.balance}
onClick={() => alert('Пополнить баланс')} onClick={() => setShowTokensModal(true)}
title="Нажмите чтобы пополнить баланс" title="Нажмите чтобы пополнить баланс"
> >
<span className={styles.balanceIcon}> <span className={styles.balanceIcon}>
@ -60,6 +89,20 @@ const Header: React.FC = () => {
</button> </button>
</div> </div>
</div> </div>
{/* Модальное окно с пакетами токенов */}
<TokenPacksModal
isVisible={showTokensModal}
onClose={() => setShowTokensModal(false)}
onShowAllPacks={() => navigate('/profile')}
missingTokens={0}
onBuyPack={(packId) => {
setShowTokensModal(false);
paymentService.showBuyTokensPopup(tokenPacks.find(p => p.id === packId)!, () => {
window.location.reload();
});
}}
/>
</header> </header>
); );
}; };

View File

@ -1,14 +1,14 @@
.layout { .layout {
min-height: 100vh; min-height: 100vh;
background-color: var(--color-background); background-color: var(--color-background);
padding-bottom: 4rem; /* Отступ для навигации */ padding-bottom: 6rem; /* Увеличенный отступ для навигации */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
position: relative; /* Добавляем относительное позиционирование */ position: relative;
height: 100%; /* Устанавливаем высоту 100% */ height: 100%;
overscroll-behavior: none; /* Предотвращает "резиновый" эффект на iOS */ overscroll-behavior: none;
} }
.main { .main {
@ -20,7 +20,7 @@
-webkit-overflow-scrolling: touch; /* Улучшаем инерционный скроллинг на iOS */ -webkit-overflow-scrolling: touch; /* Улучшаем инерционный скроллинг на iOS */
padding-top: 0px; /* Уменьшаем отступ до 20px */ padding-top: 0px; /* Уменьшаем отступ до 20px */
overscroll-behavior: contain; /* Ограничивает эффект скролла только этим элементом */ overscroll-behavior: contain; /* Ограничивает эффект скролла только этим элементом */
height: calc(100% - 4rem); /* Вычитаем высоту навигации */ height: calc(100% - 6rem); /* Вычитаем увеличенную высоту навигации */
} }
.container { .container {

View File

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

View File

@ -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<TokenPackCardProps> = ({
title,
tokens,
bonusTokens,
stickersCount,
price,
priceRub,
description,
isPopular,
isBestValue,
onBuy,
className = ''
}) => {
return (
<div
className={`${styles.card} ${isPopular ? styles.popular : ''} ${isBestValue ? styles.bestValue : ''} ${className}`}
>
{isPopular && (
<div className={`${styles.badge} ${styles.popularBadge}`}>
Популярный выбор
</div>
)}
{isBestValue && (
<div className={`${styles.badge} ${styles.bestValueBadge}`}>
Максимальная выгода
</div>
)}
<h3 className={styles.title}>{title}</h3>
<div className={styles.tokenInfo}>
<img src={images.tokenIcon} alt="Токены" className={styles.tokenIcon} />
<div className={styles.tokenCount}>
<span className={styles.baseTokens}>{tokens}</span>
{bonusTokens > 0 && (
<span className={styles.bonusTokens}>+{bonusTokens} БОНУС</span>
)}
</div>
</div>
<div className={styles.stickersCount}>
{stickersCount} стикеров
</div>
{description && (
<p className={styles.description}>{description}</p>
)}
<div className={styles.priceSection}>
<div className={styles.price}>
{price} Stars ({priceRub} )
</div>
<button
className={styles.buyButton}
onClick={onBuy}
>
КУПИТЬ
</button>
</div>
</div>
);
};
export default TokenPackCard;

View File

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

View File

@ -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<TokenPacksListProps> = ({
onBuyPack,
className = '',
compact
}) => {
const listRef = useRef<HTMLDivElement>(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 (
<div className={`${styles.container} ${className}`}>
<h2 className={styles.title}>Пакеты токенов</h2>
<div
className={`${styles.list} ${compact ? styles.horizontalScroll : ''}`}
ref={listRef}
>
{tokenPacks.map(pack => (
<TokenPackCard
key={pack.id}
{...pack}
compact={compact}
onBuy={() => onBuyPack(pack.id)}
/>
))}
</div>
</div>
);
};
export default TokenPacksList;

View File

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

View File

@ -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<TokenPacksModalProps> = ({
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 (
<div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<h2 className={styles.title}>Недостаточно токенов для генерации</h2>
<TokenPacksList
onBuyPack={onBuyPack}
compact={true}
/>
<button
className={styles.showAllButton}
onClick={(e) => {
e.stopPropagation();
onClose();
onShowAllPacks();
}}
>
Показать все пакеты
</button>
<button
className={styles.closeButton}
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
×
</button>
</div>
</div>
);
};
export default TokenPacksModal;

View File

@ -4,7 +4,7 @@ export const MOCK_USER: MockUser = {
telegramId: 12345678, telegramId: 12345678,
username: "TestUser", username: "TestUser",
avatarUrl: "/ava.jpg", avatarUrl: "/ava.jpg",
balance: 1000 balance: -5
}; };
export const MOCK_STYLES: StyleOption[] = [ export const MOCK_STYLES: StyleOption[] = [

View File

@ -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
}
];

View File

@ -2,60 +2,84 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-large); gap: var(--spacing-large);
padding: calc(3rem + var(--spacing-small)) var(--spacing-medium) var(--spacing-large);
width: 100%;
box-sizing: border-box;
} }
.header { .header {
padding: var(--spacing-medium) 0; padding: var(--spacing-small) 0;
text-align: center;
margin-bottom: 0;
} }
.title { .title {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: bold; font-weight: bold;
color: var(--color-text); color: var(--color-text);
display: inline-block;
} }
.subtitle { .subtitle {
margin-top: 0.25rem; margin-top: 0.25rem;
color: var(--color-text); color: var(--color-text);
opacity: 0.6; opacity: 0.6;
text-align: center;
} }
.uploadArea { .uploadArea {
display: flex;
justify-content: center;
align-items: center;
padding: var(--spacing-medium); padding: var(--spacing-medium);
background-color: var(--color-surface); cursor: pointer;
border-radius: var(--border-radius);
} }
.uploadBox { .uploadBox {
padding: var(--spacing-large);
border: 2px dashed var(--color-border);
border-radius: var(--border-radius);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center;
gap: var(--spacing-small); gap: var(--spacing-small);
cursor: pointer; padding: var(--spacing-large);
transition: all 0.2s; 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 { .uploadBox:hover {
border-color: var(--color-primary); background-color: var(--color-surface-variant);
background-color: var(--color-background); border-color: var(--color-primary-dark);
} }
.uploadIcon { .uploadIcon {
font-size: 3rem; font-size: 48px;
margin-bottom: var(--spacing-small); margin-bottom: var(--spacing-small);
} }
.uploadText { .uploadText {
font-size: 1.125rem; font-size: 1.1rem;
font-weight: 500; font-weight: bold;
color: var(--color-text); color: var(--color-text);
} }
.uploadHint { .uploadHint {
font-size: 0.875rem; font-size: 0.9rem;
color: var(--color-text); color: var(--color-text);
opacity: 0.6; 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);
}

View File

@ -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 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 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 ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.header}> <div className={styles.header}>
@ -13,7 +50,10 @@ const CreateSticker: React.FC = () => {
</p> </p>
</div> </div>
<div className={styles.uploadArea}> <div
className={styles.uploadArea}
onClick={handleUploadClick}
>
<div className={styles.uploadBox}> <div className={styles.uploadBox}>
<span className={styles.uploadIcon}>📷</span> <span className={styles.uploadIcon}>📷</span>
<span className={styles.uploadText}> <span className={styles.uploadText}>
@ -22,8 +62,24 @@ const CreateSticker: React.FC = () => {
<span className={styles.uploadHint}> <span className={styles.uploadHint}>
или перетащите файл сюда или перетащите файл сюда
</span> </span>
<span className={styles.tokenCost}>
Стоимость: {TOKENS_PER_GENERATION} токенов
</span>
</div> </div>
</div> </div>
<TokenPacksModal
isVisible={showTokensModal}
onClose={() => setShowTokensModal(false)}
onShowAllPacks={() => navigate('/profile')}
missingTokens={missingTokens}
onBuyPack={(packId: string) => {
setShowTokensModal(false);
paymentService.showBuyTokensPopup(tokenPacks.find(p => p.id === packId)!, () => {
window.location.reload();
});
}}
/>
</div> </div>
); );
}; };

View File

@ -1,13 +1,16 @@
import React, { useState, useCallback, useEffect, useRef } from 'react'; import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import BlockRenderer from '../components/blocks/BlockRenderer'; import BlockRenderer from '../components/blocks/BlockRenderer';
// import UploadPhotoBlock from '../components/blocks/UploadPhotoBlock'; // Не используется
import styles from './Home.module.css'; import styles from './Home.module.css';
import { homeScreenConfig } from '../config/homeScreen'; import { homeScreenConfig } from '../config/homeScreen';
import { stylePresets } from '../config/stylePresets'; import { stylePresets } from '../config/stylePresets';
import apiService from '../services/api'; import apiService from '../services/api';
import NotificationModal from '../components/shared/NotificationModal'; import NotificationModal from '../components/shared/NotificationModal';
import FeedbackHandler, { FeedbackHandlerRef } from '../components/shared/FeedbackHandler'; 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 { interface LastGenerationData {
@ -45,11 +48,11 @@ const Home: React.FC = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [promptText, setPromptText] = useState(''); const [promptText, setPromptText] = useState('');
const [showGalleryButton, setShowGalleryButton] = useState(true); const [showGalleryButton, setShowGalleryButton] = useState(true);
const [showButtons, setShowButtons] = useState(true); // Новое состояние для управления видимостью всех кнопок const [showButtons, setShowButtons] = useState(true);
const [continueButtonText, setContinueButtonText] = useState('Продолжить'); // Новое состояние для текста кнопки "Продолжить" const [continueButtonText, setContinueButtonText] = useState('Продолжить');
// Состояние для хранения данных о последней успешной генерации
const [lastGenerationData, setLastGenerationData] = useState<LastGenerationData>({}); const [lastGenerationData, setLastGenerationData] = useState<LastGenerationData>({});
const [showTokensModal, setShowTokensModal] = useState(false);
const [missingTokens, setMissingTokens] = useState(0);
// Обработчики для модального окна // Обработчики для модального окна
const handleGalleryClick = useCallback(() => { const handleGalleryClick = useCallback(() => {
@ -152,14 +155,24 @@ const Home: React.FC = () => {
} }
try { 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('Генерация стикера'); setNotificationTitle('Генерация стикера');
setNotificationMessage('Отправка запроса...'); setNotificationMessage('Отправка запроса...');
setIsLoading(true); setIsLoading(true);
setPromptText(''); setPromptText('');
setShowGalleryButton(true); // Показываем кнопку "В галерею" для уведомлений о генерации setShowGalleryButton(true);
setShowButtons(false); // Скрываем все кнопки во время отправки запроса setShowButtons(false);
setContinueButtonText('Продолжить'); // Сбрасываем текст кнопки на значение по умолчанию setContinueButtonText('Продолжить');
setIsNotificationVisible(true); setIsNotificationVisible(true);
// Если выбран "Свой промпт" и введен текст, используем его // Если выбран "Свой промпт" и введен текст, используем его
@ -356,6 +369,19 @@ const Home: React.FC = () => {
/> />
<div className={styles.content}> <div className={styles.content}>
{/* Модальное окно с пакетами токенов */}
<TokenPacksModal
isVisible={showTokensModal}
onClose={() => setShowTokensModal(false)}
onShowAllPacks={() => navigate('/profile')}
missingTokens={missingTokens}
onBuyPack={(packId: string) => {
setShowTokensModal(false);
paymentService.showBuyTokensPopup(tokenPacks.find(p => p.id === packId)!, () => {
window.location.reload();
});
}}
/>
{/* Блоки из конфигурации */} {/* Блоки из конфигурации */}
<div className={styles.blocks}> <div className={styles.blocks}>
{homeScreenConfig.homeScreen.blocks {homeScreenConfig.homeScreen.blocks

View File

@ -1,63 +1,75 @@
.container { .container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-large); gap: 12px;
padding: calc(3rem + var(--spacing-small)) var(--spacing-medium) var(--spacing-large); padding: calc(3rem + var(--spacing-small)) var(--spacing-medium) 6rem;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
.header { .header {
padding: var(--spacing-small) 0; display: flex;
flex-direction: column;
align-items: center;
text-align: center; text-align: center;
margin-bottom: 0; padding: 8px 0;
} }
.title { .title {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: bold; font-weight: bold;
color: var(--color-text); color: var(--color-text);
display: inline-block; margin: 0;
} }
.subtitle { .subtitle {
margin-top: 0.25rem; margin: 0;
color: var(--color-text); color: var(--color-text);
opacity: 0.6; opacity: 0.6;
text-align: center; font-size: 0.875rem;
} }
.card { .compactStats {
background-color: var(--color-surface); display: flex;
border-radius: var(--border-radius); justify-content: space-between;
padding: var(--spacing-medium); gap: 8px;
} margin-bottom: 12px;
.stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--spacing-medium);
} }
.statItem { .statItem {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
padding: var(--spacing-medium); padding: 12px 8px;
background-color: var(--color-background); background-color: var(--color-surface);
border-radius: var(--border-radius); border-radius: var(--border-radius);
} }
.statValue { .statValue {
font-size: 1.5rem; font-size: 18px;
font-weight: bold; font-weight: bold;
color: var(--color-primary); color: var(--color-primary);
} }
.statLabel { .statLabel {
margin-top: 0.25rem; margin-top: 4px;
color: var(--color-text); color: var(--color-text);
opacity: 0.6; 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;
}
} }

View File

@ -1,9 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import styles from './Profile.module.css'; import styles from './Profile.module.css';
import { MOCK_USER } from '../constants/mock';
import { stickerService } from '../services/stickerService'; import { stickerService } from '../services/stickerService';
import apiService from '../services/api'; import apiService from '../services/api';
import { getCurrentUserId } from '../constants/user'; 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 Profile: React.FC = () => {
const [stickersCount, setStickersCount] = useState<number>(0); const [stickersCount, setStickersCount] = useState<number>(0);
@ -32,23 +34,24 @@ const Profile: React.FC = () => {
fetchData(); fetchData();
}, []); }, []);
const handleBuyPack = (packId: string) => {
const pack = tokenPacks.find(p => p.id === packId);
if (!pack) return;
paymentService.showBuyTokensPopup(pack, () => {
// Обновляем данные после успешной оплаты
window.location.reload();
});
};
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.header}> <div className={styles.header}>
<h1 className={styles.title}> <h1 className={styles.title}>Профиль</h1>
Профиль <p className={styles.subtitle}>Ваша статистика и настройки</p>
</h1>
<p className={styles.subtitle}>
Ваша статистика и настройки
</p>
</div> </div>
<div className={styles.card}> <div className={styles.compactStats}>
<div className={styles.stats}>
<div className={styles.statItem}>
<span className={styles.statValue}>{MOCK_USER.balance}</span>
<span className={styles.statLabel}>Токенов</span>
</div>
<div className={styles.statItem}> <div className={styles.statItem}>
<span className={styles.statValue}>{loading ? '...' : stickersCount}</span> <span className={styles.statValue}>{loading ? '...' : stickersCount}</span>
<span className={styles.statLabel}>Стикеров создано</span> <span className={styles.statLabel}>Стикеров создано</span>
@ -62,7 +65,11 @@ const Profile: React.FC = () => {
<span className={styles.statLabel}>Избранных</span> <span className={styles.statLabel}>Избранных</span>
</div> </div>
</div> </div>
</div>
<TokenPacksList
onBuyPack={handleBuyPack}
className={styles.tokenPacks}
/>
</div> </div>
); );
}; };

View File

@ -48,7 +48,36 @@ class GenerationError extends Error {
} }
} }
// Временное решение для работы с балансом токенов
// В будущем будет заменено на API-запросы
let mockBalance = -5; // Начальное значение из MOCK_USER
const apiService = { const apiService = {
// Метод для списания токенов
async deductTokens(userId: number, amount: number): Promise<boolean> {
try {
// В будущем здесь будет API-запрос для списания токенов
// Пока реализуем локальное списание
mockBalance -= amount;
return true;
} catch (error) {
console.error('Error deducting tokens:', error);
return false;
}
},
// Метод для получения текущего баланса
async getBalance(userId: number): Promise<number> {
try {
// В будущем здесь будет API-запрос для получения баланса
// Пока возвращаем локальное значение
return mockBalance;
} catch (error) {
console.error('Error getting balance:', error);
throw error;
}
},
// Получение списка задач пользователя в статусе PENDING // Получение списка задач пользователя в статусе PENDING
async getUserPendingTasks(userId = getCurrentUserId()): Promise<PendingTask[]> { async getUserPendingTasks(userId = getCurrentUserId()): Promise<PendingTask[]> {
try { try {

View File

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

View File

@ -15,6 +15,18 @@ interface TelegramWebAppInitData {
hash?: string; hash?: string;
} }
interface PopupButton {
id?: string;
type: 'ok' | 'close' | 'cancel';
text?: string;
}
interface PopupParams {
title?: string;
message: string;
buttons?: PopupButton[];
}
interface TelegramWebApp { interface TelegramWebApp {
initData: string; initData: string;
initDataUnsafe: TelegramWebAppInitData; initDataUnsafe: TelegramWebAppInitData;
@ -40,6 +52,8 @@ interface TelegramWebApp {
openTelegramLink(url: string): void; openTelegramLink(url: string): void;
showAlert(message: string, callback?: () => void): void; showAlert(message: string, callback?: () => void): void;
showConfirm(message: string, callback?: (confirmed: boolean) => 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: { MainButton: {
text: string; text: string;
color: string; color: string;