рупное обновление: добавлен редактор стикеров, offerwall, обновлены UI компоненты

-  обавлен редактор стикеров (StickerEditorScreen.tsx)
- 💰 обавлена offerwall функциональность
- 🎨 обавлены Figma дизайн-файлы
- 🔧 обавлены конфигурации редактора (editorFonts.ts, editorStickers.ts)
- 🎯 бновлены компоненты: Gallery, Profile, Header, TokenPacks
- 📱 бновлены onboarding экраны и стили
- 📦 бновлены зависимости проекта
- ��️ обавлены новые изображения для onboarding
- 📋 обавлен план реализации редактора стикеров
This commit is contained in:
kazachilo 2025-06-30 16:22:55 +03:00
parent 8790c32855
commit 971d76e3d5
42 changed files with 2914 additions and 354 deletions

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="402" height="80" fill="none" viewBox="0 0 402 80">
<rect width="31" height="4" x="145.5" y="12" fill="#008C09" rx="2"/>
<rect width="31" height="4" x="184.5" y="12" fill="#3C3C43" fill-opacity=".6" opacity=".3" rx="2"/>
<rect width="31" height="4" x="223.5" y="12" fill="#3C3C43" fill-opacity=".6" opacity=".3" rx="2"/>
<rect width="370" height="48" x="16" y="32" fill="#008C09" rx="12"/>
<path fill="#fff" d="M170.274 64.366h-1.942v-4.167h.855c.288-.183.515-.415.681-.697.166-.283.287-.623.365-1.022.083-.403.141-.871.174-1.402l.424-7.056h8.217v10.177h1.494v4.167h-1.95V62h-8.318v2.366Zm2.109-7.164a13 13 0 0 1-.125 1.196 4.716 4.716 0 0 1-.24.937 2.4 2.4 0 0 1-.424.74v.124h5.321v-8.376h-4.192l-.34 5.38Zm12.137 4.947c-.57 0-1.082-.11-1.535-.332a2.606 2.606 0 0 1-1.063-.938c-.255-.41-.382-.89-.382-1.444v-.017c0-.536.133-.998.399-1.386.265-.393.655-.7 1.17-.921.515-.221 1.14-.352 1.876-.39l3.354-.208v1.361l-3.063.2c-.581.033-1.01.155-1.287.365-.277.21-.415.504-.415.88v.016c0 .388.147.69.44.905.299.216.678.324 1.137.324.415 0 .786-.083 1.112-.25.327-.165.584-.39.772-.671a1.74 1.74 0 0 0 .283-.972V55.8c0-.453-.144-.8-.432-1.037-.288-.244-.714-.365-1.278-.365-.471 0-.855.083-1.154.248-.299.161-.501.39-.606.69l-.008.033h-1.951l.008-.075c.067-.51.266-.955.598-1.337.332-.381.772-.677 1.32-.888.547-.21 1.178-.315 1.892-.315.786 0 1.45.122 1.992.365.543.238.955.587 1.237 1.046.282.454.424.999.424 1.635V62h-2.042v-1.245h-.142a2.602 2.602 0 0 1-.647.747c-.26.21-.559.37-.897.481a3.54 3.54 0 0 1-1.112.166Zm9.681-4.075c-.056.869-.189 1.602-.399 2.2-.205.597-.506 1.048-.905 1.352-.393.305-.899.457-1.519.457a2.93 2.93 0 0 1-.473-.033 2.134 2.134 0 0 1-.299-.067v-1.776c.05.011.125.025.224.041.1.012.208.017.324.017a.803.803 0 0 0 .664-.315c.166-.216.291-.501.374-.855.083-.354.138-.75.166-1.187l.315-4.98h6.508V62h-2.067v-7.47h-2.648l-.265 3.544ZM201.432 62v-9.073h2.067v2.74h1.785c1.062 0 1.906.287 2.531.863.631.57.947 1.336.947 2.3v.016c0 .963-.316 1.729-.947 2.299-.625.57-1.469.855-2.531.855h-3.852Zm3.586-4.74h-1.519v3.146h1.519c.509 0 .913-.144 1.212-.431.299-.294.448-.673.448-1.138v-.016c0-.47-.149-.847-.448-1.13-.299-.287-.703-.43-1.212-.43Zm5.43 4.74v-9.073h2.067v7.454h2.889v-7.454h2.059v7.454h2.888v-7.454h2.067V62h-11.97Zm18.106.183c-.902 0-1.677-.191-2.324-.573a3.82 3.82 0 0 1-1.486-1.627c-.349-.703-.523-1.536-.523-2.499v-.008c0-.952.172-1.782.515-2.49a3.876 3.876 0 0 1 1.477-1.644c.637-.393 1.384-.59 2.241-.59.864 0 1.605.192 2.225.574a3.747 3.747 0 0 1 1.444 1.585c.338.68.507 1.478.507 2.39v.681h-7.371v-1.386h6.358l-.979 1.295v-.822c0-.603-.092-1.104-.274-1.502-.183-.398-.438-.697-.764-.897a2.083 2.083 0 0 0-1.121-.298 2.08 2.08 0 0 0-1.137.315c-.326.205-.586.51-.78.913-.188.398-.282.888-.282 1.47v.83c0 .558.094 1.037.282 1.435.188.393.454.698.797.913.348.21.761.316 1.237.316.37 0 .689-.053.954-.158.271-.11.49-.24.656-.39a1.56 1.56 0 0 0 .349-.44l.024-.058h1.959l-.016.075a3.015 3.015 0 0 1-.399.896 3.388 3.388 0 0 1-.772.839 3.952 3.952 0 0 1-1.178.622c-.465.155-1.005.233-1.619.233Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

BIN
figma/Frame 1340414261.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 3.7 MiB

3
figma/Home Indicator.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="402" height="21" fill="none" viewBox="0 0 402 21">
<rect width="134" height="4" x="134" y="10" fill="#000" rx="2"/>
</svg>

After

Width:  |  Height:  |  Size: 172 B

BIN
figma/export.zip Normal file

Binary file not shown.

BIN
figma/onboarding_img1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

BIN
figma/onboarding_img1.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

BIN
figma/onboarding_img2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

91
package-lock.json generated
View File

@ -9,8 +9,10 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.66.3", "@tanstack/react-query": "^5.66.3",
"konva": "^9.3.20",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-konva": "^19.0.3",
"react-router-dom": "^7.1.5", "react-router-dom": "^7.1.5",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
@ -1421,7 +1423,6 @@
"version": "19.0.10", "version": "19.0.10",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz",
"integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
@ -1437,6 +1438,15 @@
"@types/react": "^19.0.0" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.24.0", "version": "8.24.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.24.0.tgz",
@ -1913,7 +1923,6 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/debug": { "node_modules/debug": {
@ -2459,6 +2468,18 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -2536,6 +2557,26 @@
"json-buffer": "3.0.1" "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": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -2854,6 +2895,52 @@
"react": "^19.0.0" "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": { "node_modules/react-refresh": {
"version": "0.14.2", "version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",

View File

@ -5,14 +5,17 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:network": "vite --host 0.0.0.0 --port 5173",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.66.3", "@tanstack/react-query": "^5.66.3",
"konva": "^9.3.20",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-konva": "^19.0.3",
"react-router-dom": "^7.1.5", "react-router-dom": "^7.1.5",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },

4
payment_request.json Normal file
View File

@ -0,0 +1,4 @@
{
"user_id": 296487847,
"description": "стартовый набор"
}

View File

@ -21,6 +21,7 @@ const CreateStickerPack = lazy(() => import('./screens/CreateStickerPack'));
const AddStickerToPackScreen = lazy(() => import('./screens/AddStickerToPackScreen')); const AddStickerToPackScreen = lazy(() => import('./screens/AddStickerToPackScreen'));
const History = lazy(() => import('./screens/History')); const History = lazy(() => import('./screens/History'));
const CropPhoto = lazy(() => import('./screens/CropPhoto')); const CropPhoto = lazy(() => import('./screens/CropPhoto'));
const StickerEditorScreen = lazy(() => import('./screens/StickerEditorScreen'));
// Компонент для отображения состояния загрузки // Компонент для отображения состояния загрузки
const LoadingScreen = () => ( const LoadingScreen = () => (
@ -69,14 +70,12 @@ const AppContent: React.FC = () => {
const hasSeenOnboarding = localStorage.getItem('hasSeenOnboarding') === 'true'; const hasSeenOnboarding = localStorage.getItem('hasSeenOnboarding') === 'true';
const hasAcceptedTerms = localStorage.getItem('hasAcceptedTerms') === 'true'; const hasAcceptedTerms = localStorage.getItem('hasAcceptedTerms') === 'true';
// Если не видел онбординг и не на странице онбординга или условий // Если не видел онбординг и не на странице онбординга
if (!hasSeenOnboarding && !location.pathname.includes('/onboarding')) { if (!hasSeenOnboarding && !location.pathname.includes('/onboarding')) {
navigate('/onboarding/welcome'); navigate('/onboarding/welcome');
} }
// Если видел онбординг, но не принял условия и не на странице условий // Условие перенаправления на экран Terms and Conditions удалено,
else if (hasSeenOnboarding && !hasAcceptedTerms && !location.pathname.includes('/onboarding/terms')) { // так как пользователь соглашается с условиями на экране How-to
navigate('/onboarding/terms');
}
}, []); }, []);
// Отслеживаем изменение маршрута для аналитики // Отслеживаем изменение маршрута для аналитики
@ -156,6 +155,11 @@ const AppContent: React.FC = () => {
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
<Route path="/history" element={<History />} /> <Route path="/history" element={<History />} />
<Route path="/crop-photo" element={<CropPhoto />} /> <Route path="/crop-photo" element={<CropPhoto />} />
<Route path="/edit/:stickerId" element={
<Suspense fallback={<LoadingScreen />}>
<StickerEditorScreen />
</Suspense>
} />
</Route> </Route>
</Routes> </Routes>
); );

View File

@ -7,6 +7,8 @@ import emotions from './emotions_promo250x.webp';
import realism from './realism_promo250x.webp'; import realism from './realism_promo250x.webp';
import prompt from './prompt.webp'; import prompt from './prompt.webp';
import onboard1 from './onboard1.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 santa from './250x_santa.webp';
import balloon from './balloon250x.webp'; import balloon from './balloon250x.webp';
import bicycle from './bicecle250x.webp'; import bicycle from './bicecle250x.webp';
@ -86,6 +88,10 @@ import emoDrStrange from './emo/35.webp';
import emoCaptainAmerica from './emo/36.webp'; import emoCaptainAmerica from './emo/36.webp';
export const images = { export const images = {
onboarding_img2,
scateboard250x: skateboard,
fairy250x: fairy,
emotions_promo250x: emotions,
ahareBot, ahareBot,
faq, faq,
shorts, shorts,
@ -95,6 +101,7 @@ export const images = {
realism, realism,
prompt, prompt,
onboard1, onboard1,
onboardingWelcome,
santa, santa,
balloon, balloon,
bicycle, bicycle,

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@ -105,7 +105,7 @@ const Header: React.FC = () => {
onClose={() => setShowTokensModal(false)} onClose={() => setShowTokensModal(false)}
onShowAllPacks={() => navigate('/profile')} onShowAllPacks={() => navigate('/profile')}
missingTokens={0} missingTokens={0}
onBuyPack={(packId) => { onBuyPack={(packId, showRub) => {
const pack = tokenPacks.find(p => p.id === packId); const pack = tokenPacks.find(p => p.id === packId);
if (!pack) return; if (!pack) return;
@ -119,33 +119,38 @@ const Header: React.FC = () => {
setShowTokensModal(false); setShowTokensModal(false);
setLastPurchasedPack(pack); setLastPurchasedPack(pack);
paymentService.showBuyTokensPopup(pack, async (userData) => { if (showRub) {
if (userData) { // Используем оплату рублями
paymentService.showRubPaymentPopup(pack.title, () => {
// Обновляем баланс через контекст // Обновляем баланс через контекст
updateBalance(); updateBalance();
// Показываем модальное окно с информацией об успешной оплате // Не показываем модальное окно с информацией об успешной оплате
setNotificationTitle(getTranslation('payment_success')); // Просто обновляем баланс
setNotificationMessage(getTranslation('tokens_purchased', pack.tokens + pack.bonusTokens)); });
setShowNotificationModal(true);
} else { } else {
// Если данные не получены, делаем запрос на получение данных пользователя // Используем оплату звездами
paymentService.showBuyTokensPopup(pack, async (userData) => {
// Обновляем баланс через контекст, независимо от того, получены ли данные пользователя
updateBalance();
// Не показываем модальное окно с информацией об успешной оплате
// Просто обновляем баланс
// Если возникла ошибка при получении данных, логируем её
if (!userData) {
try { try {
// Получаем баланс пользователя // Получаем баланс пользователя
const balance = await apiService.getBalance(getCurrentUserId()); await apiService.getBalance(getCurrentUserId());
// Обновляем баланс через контекст // Обновляем баланс через контекст еще раз
updateBalance(); updateBalance();
// Показываем модальное окно с информацией об успешной оплате
setNotificationTitle(getTranslation('payment_success'));
setNotificationMessage(getTranslation('profile_tokens_purchased', pack.tokens + pack.bonusTokens, balance));
setShowNotificationModal(true);
} catch (error) { } catch (error) {
console.error('Ошибка при обновлении данных пользователя:', error); console.error('Ошибка при обновлении данных пользователя:', error);
} }
} }
}); });
}
}} }}
/> />

View File

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

View File

@ -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<HTMLDivElement, OfferWallCardProps>((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 (
<div
className={cardClasses}
style={cardStyle}
ref={ref}
onClick={onBuy} // Добавляем обработчик клика на всю карточку
>
{/* Бейджи */}
{isPopular && !badgeImg && (
<div className={styles.popularBadge}>Популярный выбор</div>
)}
{isBestValue && !badgeImg && (
<div className={styles.bestValueBadge}>Максимальная выгода</div>
)}
{badgeImg && (
<img src={badgeImg} alt="Бейдж" className={styles.badgeImg} />
)}
{/* Звездочка теперь отображается через псевдоэлемент в родительском контейнере */}
{discountPercent > 0 && (
<div className={styles.discount}>-{discountPercent}%</div>
)}
<div className={styles.content}>
{/* Заголовок с переносом второго слова на новую строку */}
<h3 className={styles.title}>
<div>
{title.split(' ').length > 1 ? (
<>
{title.split(' ')[0]}<br />
{title.split(' ').slice(1).join(' ')}
</>
) : (
title
)}
</div>
{props.bestValueLabel && (
<span className={styles.bestValueLabel}>Максимальная выгода</span>
)}
</h3>
{/* Белая отсекающая полоска под названием */}
<div className={styles.titleDivider}></div>
{/* Информация о токенах и стикерах */}
<div className={styles.infoList}>
<div className={styles.infoItem}>
<span className={styles.bullet}></span>
<span>{stickersCount} стикеров</span>
</div>
<div className={styles.infoItem}>
<span className={styles.bullet}></span>
<div className={styles.tokenInfo}>
<span>{tokens}</span>
<img src={images.tokenIcon} alt="Токены" className={styles.tokenIcon} />
</div>
</div>
{bonusTokens > 0 && (
<div className={styles.infoItem}>
<span className={styles.bullet}></span>
<span>{bonusTokens} бонусов</span>
</div>
)}
</div>
{/* Фото (если есть) */}
{image && (
<div className={styles.imageContainer}>
<img src={image} alt="Фото" />
</div>
)}
{/* Цена и кнопка */}
<div className={`${styles.priceSection} ${fullWidth ? styles.priceSectionHorizontal : styles.priceSectionVertical}`}>
<div className={styles.priceWrapper}>
{discountPercent > 0 && showRub ? (
<>
<span className={styles.originalPrice}>
{originalPriceRub} <span className={styles.rubSymbol}></span>
</span>
<span className={styles.price}>
{priceRub} <span className={styles.rubSymbol}></span>
</span>
</>
) : discountPercent > 0 && !showRub ? (
<>
<span className={styles.originalPrice}>
{originalPrice} <span className={styles.tokenSymbol}></span>
</span>
<span className={styles.price}>
{price} <span className={styles.tokenSymbol}></span>
</span>
</>
) : (
<span className={styles.price}>
{showRub ? priceRub : price}
<span className={showRub ? styles.rubSymbol : styles.tokenSymbol}>
{showRub ? '₽' : '⭐'}
</span>
</span>
)}
</div>
<button
className={styles.buyButton}
onClick={(e) => {
e.stopPropagation(); // Предотвращаем всплытие события
onBuy && onBuy();
}}
>
Купить
</button>
</div>
</div>
</div>
);
});
export default OfferWallCard;

View File

@ -1,29 +1,27 @@
.container { .container {
min-height: 100vh; min-height: 100vh;
height: 100vh; /* Фиксированная высота */
overflow: hidden; /* Предотвращает скроллинг */
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: flex-start;
align-items: center; align-items: center;
padding: var(--spacing-medium); padding: var(--spacing-medium);
background-color: var(--color-background); padding-top: var(--spacing-medium);
padding-bottom: 0; /* Убираем отступ снизу */
background-color: #ffffff; /* Возвращаем белый цвет фону */
color: var(--color-text); color: var(--color-text);
} }
.content { .content {
max-width: 28rem; max-width: 28rem;
width: 100%; width: 100%;
max-height: 90vh; /* Ограничение высоты */ background-color: transparent;
overflow: auto; /* Если контент всё же не помещается, добавляем скролл только внутри контейнера */ padding: calc(var(--spacing-large) / 2) var(--spacing-medium) 0; /* Убираем отступ снизу */
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);
color: var(--color-text); color: var(--color-text);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
animation: fadeIn 0.3s ease-in-out; animation: fadeIn 0.3s ease-in-out;
overflow-y: auto; /* Добавляем возможность скроллинга */
max-height: 100vh; /* Ограничиваем высоту, чтобы появился скроллинг */
} }
@keyframes fadeIn { @keyframes fadeIn {
@ -34,9 +32,14 @@
.title { .title {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
margin-bottom: var(--spacing-medium); margin-bottom: var(--spacing-small);
color: var(--color-text); color: var(--color-text);
text-align: center; text-align: center;
position: sticky;
top: 0;
z-index: 10;
background-color: #ffffff; /* Добавляем белый фон, чтобы текст не сливался с контентом при скроллинге */
padding-top: var(--spacing-small);
} }
.description { .description {

View File

@ -13,6 +13,8 @@ interface OnboardingLayoutProps {
secondaryButtonText?: string; secondaryButtonText?: string;
onPrimaryClick: () => void; onPrimaryClick: () => void;
onSecondaryClick?: () => void; onSecondaryClick?: () => void;
showProgressDots?: boolean;
showButtons?: boolean;
} }
const OnboardingLayout: React.FC<OnboardingLayoutProps> = ({ const OnboardingLayout: React.FC<OnboardingLayoutProps> = ({
@ -25,7 +27,9 @@ const OnboardingLayout: React.FC<OnboardingLayoutProps> = ({
primaryButtonText, primaryButtonText,
secondaryButtonText, secondaryButtonText,
onPrimaryClick, onPrimaryClick,
onSecondaryClick onSecondaryClick,
showProgressDots = true,
showButtons = true
}) => { }) => {
return ( return (
<div className={styles.container}> <div className={styles.container}>
@ -48,8 +52,11 @@ const OnboardingLayout: React.FC<OnboardingLayoutProps> = ({
</div> </div>
)} )}
{showProgressDots && (
<ProgressDots currentStep={currentStep} totalSteps={totalSteps} /> <ProgressDots currentStep={currentStep} totalSteps={totalSteps} />
)}
{showButtons && (
<div className={styles.buttonsContainer}> <div className={styles.buttonsContainer}>
<button <button
className={styles.primaryButton} className={styles.primaryButton}
@ -67,6 +74,7 @@ const OnboardingLayout: React.FC<OnboardingLayoutProps> = ({
</button> </button>
)} )}
</div> </div>
)}
</div> </div>
</div> </div>
); );

View File

@ -39,6 +39,53 @@
text-align: center; 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 { .subtitle {
margin: 0 0 16px 0; margin: 0 0 16px 0;
font-size: 14px; font-size: 14px;
@ -46,21 +93,18 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
.horizontalScroll { /* Контейнер для одной карточки */
.singleOfferContainer {
display: flex; display: flex;
overflow-x: auto; justify-content: center;
width: 100%;
padding: 8px 0; padding: 8px 0;
-webkit-overflow-scrolling: touch; margin: 0 auto;
scroll-snap-type: x mandatory;
gap: 16px;
margin: 0 -8px;
padding: 8px;
} }
.horizontalScroll > * { .singleOfferContainer > div {
flex-shrink: 0; width: 100%;
width: 280px; max-width: 320px;
scroll-snap-align: start;
} }
.showAllButton { .showAllButton {
@ -103,21 +147,14 @@
background-color: rgba(0, 0, 0, 0.05); background-color: rgba(0, 0, 0, 0.05);
} }
/* Стилизация скроллбара */ /* Адаптивность */
.horizontalScroll::-webkit-scrollbar { @media (max-width: 600px) {
height: 6px; .priceToggle {
max-width: 220px;
} }
.horizontalScroll::-webkit-scrollbar-track { .toggleButton {
background: var(--color-surface-variant); font-size: 12px;
border-radius: 3px; padding: 6px 8px;
} }
.horizontalScroll::-webkit-scrollbar-thumb {
background: var(--color-primary);
border-radius: 3px;
}
.horizontalScroll::-webkit-scrollbar-thumb:hover {
background: var(--color-primary-dark);
} }

View File

@ -1,15 +1,15 @@
import React from 'react'; import React, { useState } from 'react';
import { tokenPacks, TokenPack } from '../../constants/tokenPacks'; import { tokenPacks, TokenPack } from '../../constants/tokenPacks';
import styles from './TokenPacksModal.module.css'; import styles from './TokenPacksModal.module.css';
import TokenPacksList from './TokenPacksList';
import { getTranslation } from '../../constants/translations'; import { getTranslation } from '../../constants/translations';
import OfferWallCard from '../offerwall/OfferWallCard';
interface TokenPacksModalProps { interface TokenPacksModalProps {
isVisible: boolean; isVisible: boolean;
onClose: () => void; onClose: () => void;
onShowAllPacks: () => void; onShowAllPacks: () => void;
missingTokens: number; missingTokens: number;
onBuyPack: (packId: string) => void; onBuyPack: (packId: string, showRub: boolean) => void;
} }
const TokenPacksModal: React.FC<TokenPacksModalProps> = ({ const TokenPacksModal: React.FC<TokenPacksModalProps> = ({
@ -33,15 +33,52 @@ const TokenPacksModal: React.FC<TokenPacksModalProps> = ({
); );
const displayPacks = tokenPacks.slice(startIndex, startIndex + 3); const displayPacks = tokenPacks.slice(startIndex, startIndex + 3);
// Состояние для переключателя "рубли/звезды"
const [showRub, setShowRub] = useState<boolean>(true);
return ( return (
<div className={styles.overlay} onClick={onClose}> <div className={styles.overlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}> <div className={styles.modal} onClick={e => e.stopPropagation()}>
<h2 className={styles.title}>{getTranslation('token_modal_title')}</h2> <h2 className={styles.title}>Докупить токенов</h2>
<TokenPacksList {/* Переключатель цен */}
onBuyPack={onBuyPack} <div className={styles.priceToggle}>
compact={true} <button
className={`${styles.toggleButton} ${showRub ? styles.active : ''}`}
onClick={() => setShowRub(true)}
>
Цена за <span className={styles.rubSymbol}></span>
</button>
<button
className={`${styles.toggleButton} ${!showRub ? styles.active : ''}`}
onClick={() => setShowRub(false)}
>
Цена за <span className={styles.tokenSymbol}></span>
</button>
</div>
{/* Контейнер для одной карточки */}
<div className={styles.singleOfferContainer}>
<OfferWallCard
key={tokenPacks[2].id} // "Стикерный запас" (третий пакет)
title={tokenPacks[2].title}
tokens={tokenPacks[2].tokens}
bonusTokens={tokenPacks[2].bonusTokens}
stickersCount={tokenPacks[2].stickersCount}
price={tokenPacks[2].price}
originalPrice={tokenPacks[2].originalPrice}
priceRub={tokenPacks[2].priceRub}
originalPriceRub={tokenPacks[2].originalPriceRub}
discountPercent={tokenPacks[2].discountPercent}
description={tokenPacks[2].description}
isPopular={tokenPacks[2].isPopular}
isBestValue={tokenPacks[2].isBestValue}
backgroundColor="#2196F3" // Синий цвет для "Стикерный запас"
fullWidth={false}
showRub={showRub} // Используем состояние переключателя
onBuy={() => onBuyPack(tokenPacks[2].id, showRub)}
/> />
</div>
<button <button
className={styles.showAllButton} className={styles.showAllButton}

View File

@ -31,7 +31,7 @@ const TokenPacksModalContainer: React.FC<TokenPacksModalContainerProps> = ({
/** /**
* Обработчик покупки пакета токенов * Обработчик покупки пакета токенов
*/ */
const handleBuyPack = useCallback((packId: string) => { const handleBuyPack = useCallback((packId: string, showRub: boolean) => {
const pack = tokenPacks.find(p => p.id === packId); const pack = tokenPacks.find(p => p.id === packId);
if (!pack) return; if (!pack) return;
@ -43,8 +43,22 @@ const TokenPacksModalContainer: React.FC<TokenPacksModalContainerProps> = ({
}); });
attemptedRef.current = true; attemptedRef.current = true;
onClose(); if (showRub) {
// Используем оплату рублями
paymentService.showRubPaymentPopup(pack.title, () => {
// Обновляем баланс с повторными попытками
updateBalanceWithRetries(updateBalance);
// Вызываем колбэк успешной покупки, если он передан
if (onSuccess) {
onSuccess();
}
// Закрываем модальное окно после успешной оплаты
onClose();
});
} else {
// Используем оплату звездами
paymentService.showBuyTokensPopup(pack, async (userData) => { paymentService.showBuyTokensPopup(pack, async (userData) => {
// Обновляем баланс с повторными попытками // Обновляем баланс с повторными попытками
updateBalanceWithRetries(updateBalance); updateBalanceWithRetries(updateBalance);
@ -53,7 +67,11 @@ const TokenPacksModalContainer: React.FC<TokenPacksModalContainerProps> = ({
if (onSuccess) { if (onSuccess) {
onSuccess(); onSuccess();
} }
// Закрываем модальное окно после успешной оплаты
onClose();
}); });
}
}, [onClose, updateBalance, onSuccess]); }, [onClose, updateBalance, onSuccess]);
/** /**

29
src/config/editorFonts.ts Normal file
View File

@ -0,0 +1,29 @@
/**
* Конфиг шрифтов для редактора стикеров.
* Добавляйте сюда новые шрифты по мере необходимости.
* Для подключения Google Fonts используйте <link> в index.html или динамическую загрузку.
*/
export interface EditorFont {
name: string; // Название для отображения в UI
family: string; // CSS font-family
url?: string; // URL для подключения (Google Fonts)
}
export const editorFonts: EditorFont[] = [
{
name: 'Roboto',
family: 'Roboto, Arial, sans-serif',
url: 'https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap'
},
{
name: 'Montserrat',
family: 'Montserrat, Arial, sans-serif',
url: 'https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap'
},
{
name: 'Russo One',
family: '"Russo One", Arial, sans-serif',
url: 'https://fonts.googleapis.com/css2?family=Russo+One&display=swap'
}
// Добавляйте новые шрифты по аналогии
];

View File

@ -0,0 +1,24 @@
/**
* Конфиг эмодзи/стикеров для редактора.
* Добавляйте новые PNG/SVG/WebP-файлы в src/assets/editor-stickers/ и прописывайте их здесь.
*/
export interface EditorSticker {
name: string; // Название для отображения в UI
src: string; // Путь к файлу (относительно src/assets/editor-stickers/)
}
export const editorStickers: EditorSticker[] = [
{
name: 'Star',
src: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80"><polygon points="40,5 50,30 77,30 55,47 63,73 40,58 17,73 25,47 3,30 30,30" fill="gold" stroke="orange" stroke-width="3"/></svg>'
},
{
name: 'Heart',
src: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80"><path d="M40 70 Q10 40 40 20 Q70 40 40 70 Z" fill="red" stroke="darkred" stroke-width="3"/></svg>'
},
{
name: 'Smile',
src: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80" viewBox="0 0 80 80"><circle cx="40" cy="40" r="35" fill="yellow" stroke="orange" stroke-width="3"/><circle cx="30" cy="35" r="5" fill="black"/><circle cx="50" cy="35" r="5" fill="black"/><path d="M30 55 Q40 65 50 55" stroke="black" stroke-width="3" fill="none"/></svg>'
}
// Добавляйте новые стикеры по аналогии
];

View File

@ -6,8 +6,11 @@ export interface TokenPack {
tokens: number; tokens: number;
bonusTokens: number; bonusTokens: number;
stickersCount: number; stickersCount: number;
price: number; price: number; // Финальная цена в звездах
priceRub: number; originalPrice: number; // Базовая цена в звездах без скидки
priceRub: number; // Финальная цена в рублях
originalPriceRub: number; // Базовая цена в рублях без скидки
discountPercent: number; // Процент скидки (0-100)
description: string; description: string;
isPopular?: boolean; isPopular?: boolean;
isBestValue?: boolean; isBestValue?: boolean;
@ -16,54 +19,67 @@ export interface TokenPack {
export const tokenPacks: TokenPack[] = [ export const tokenPacks: TokenPack[] = [
{ {
id: 'basic', id: 'basic',
title: 'Стартовый набор стикеромана', title: 'СТАРТОВЫЙ НАБОР',
tokens: 150, tokens: 150,
bonusTokens: 0, bonusTokens: 0,
stickersCount: 15, stickersCount: 15,
price: 100, price: 150, // Финальная цена в звездах
priceRub: 179, originalPrice: 150, // Базовая цена в звездах
priceRub: 150,
originalPriceRub: 150, // Без скидки
discountPercent: 0, // 0% скидки
description: 'Идеальный вариант для начала! Сгенерируйте 15 уникальных стикеров для вашего Telegram.' description: 'Идеальный вариант для начала! Сгенерируйте 15 уникальных стикеров для вашего Telegram.'
}, },
{ {
id: 'optimal', id: 'advanced',
title: 'Стикерный запас', title: 'СТИКЕРНЫЙ ЭНТУЗИАСТ',
tokens: 250, tokens: 250,
bonusTokens: 30, bonusTokens: 30,
stickersCount: 28, stickersCount: 28,
price: 150, price: 250, // Финальная цена в звездах
priceRub: 259, originalPrice: 280, // Базовая цена в звездах
description: 'Создавайте стикеры для всех случаев жизни с оптимальным запасом токенов.', priceRub: 249,
isPopular: true originalPriceRub: 280, // Базовая цена (28 стикеров × 10₽)
discountPercent: 11, // 11% скидки
description: 'Создавайте стикеры для всех случаев жизни с оптимальным запасом токенов.'
}, },
{ {
id: 'advanced', id: 'optimal',
title: 'Стикерный энтузиаст', title: 'СТИКЕРНЫЙ ЗАПАС',
tokens: 500, tokens: 500,
bonusTokens: 75, bonusTokens: 75,
stickersCount: 57, stickersCount: 57,
price: 350, price: 350, // Финальная цена в звездах
priceRub: 589, originalPrice: 570, // Базовая цена в звездах
priceRub: 399,
originalPriceRub: 570, // Базовая цена (57 стикеров × 10₽)
discountPercent: 30, // 30% скидки
description: 'Расширьте свои возможности! Создавайте стикеры без ограничений.' description: 'Расширьте свои возможности! Создавайте стикеры без ограничений.'
}, },
{ {
id: 'super', id: 'super',
title: 'Стикерный магнат', title: 'СТИКЕРНЫЙ МАГНАТ',
tokens: 750, tokens: 750,
bonusTokens: 150, bonusTokens: 150,
stickersCount: 90, stickersCount: 90,
price: 500, price: 500, // Финальная цена в звездах
priceRub: 839, originalPrice: 900, // Базовая цена в звездах
priceRub: 699,
originalPriceRub: 900, // Базовая цена (90 стикеров × 10₽)
discountPercent: 22, // 22% скидки
description: 'Для самых требовательных. Создавайте целые коллекции стикеров с максимальной выгодой.' description: 'Для самых требовательных. Создавайте целые коллекции стикеров с максимальной выгодой.'
}, },
{ {
id: 'unlimited', id: 'unlimited',
title: ог стикеров', title: ОГ СТИКЕРОВ',
tokens: 1500, tokens: 1500,
bonusTokens: 450, bonusTokens: 450,
stickersCount: 195, stickersCount: 195,
price: 1000, price: 1000, // Финальная цена в звездах
priceRub: 1659, originalPrice: 1950, // Базовая цена в звездах
description: 'Для профессионалов и настоящих ценителей. Неограниченные возможности для творчества с максимальной выгодой.', priceRub: 999,
isBestValue: true originalPriceRub: 1950, // Базовая цена (195 стикеров × 10₽)
discountPercent: 49, // 49% скидки
description: 'Для профессионалов и настоящих ценителей. Неограниченные возможности для творчества с максимальной выгодой.'
} }
]; ];

View File

@ -119,6 +119,32 @@
position: relative; position: relative;
} }
/* Кнопка "Редактировать" на стикере */
.editButton {
position: absolute;
top: 8px;
left: 8px;
width: 28px;
height: 28px;
background: rgba(0,0,0,0.55);
color: #fff;
border: none;
border-radius: 50%;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
cursor: pointer;
transition: background 0.2s;
box-shadow: 0 1px 4px rgba(0,0,0,0.12);
outline: none;
padding: 0;
}
.editButton:active {
background: rgba(0,0,0,0.8);
}
.deleteMode .imageItem { .deleteMode .imageItem {
opacity: 0.8; opacity: 0.8;
} }

View File

@ -342,6 +342,47 @@ const GalleryScreen: React.FC = () => {
onTouchEnd={() => !isDeleteMode && cancelLongPressTimer()} onTouchEnd={() => !isDeleteMode && cancelLongPressTimer()}
onTouchMove={() => !isDeleteMode && cancelLongPressTimer()} onTouchMove={() => !isDeleteMode && cancelLongPressTimer()}
> >
{/* Кнопка "Редактировать" */}
{!isDeleteMode && (
<button
className={styles.editButton}
title={getTranslation('gallery_edit')}
onClick={async (e) => {
e.stopPropagation();
// Создаём новый <img> с crossOrigin="anonymous"
const imageUrl = image.url || '';
const img = new window.Image();
img.crossOrigin = "anonymous";
img.src = imageUrl;
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 512;
canvas.height = 512;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.clearRect(0, 0, 512, 512);
ctx.drawImage(img, 0, 0, 512, 512);
let dataUrl = '';
try {
dataUrl = canvas.toDataURL('image/webp');
} catch {
dataUrl = canvas.toDataURL();
}
navigate(`/edit/${image.id}`, { state: { stickerDataUrl: dataUrl } });
} else {
// fallback если не удалось получить контекст
navigate(`/edit/${image.id}`);
}
};
img.onerror = () => {
// fallback если не удалось получить dataURL
navigate(`/edit/${image.id}`);
};
}}
>
</button>
)}
<ImageWithFallback <ImageWithFallback
src={image.url || ''} src={image.url || ''}
alt={getTranslation('gallery_sticker_alt', index + 1)} alt={getTranslation('gallery_sticker_alt', index + 1)}

View File

@ -60,17 +60,194 @@
font-size: 0.75rem; font-size: 0.75rem;
} }
.tokenPacks { /* Переключатель цен */
margin-top: 12px; .priceToggle {
padding: 0 8px; /* Добавить боковые отступы */ display: flex;
justify-content: center;
align-items: center;
background-color: #f0f0f0;
border-radius: 24px;
padding: 4px;
margin: 12px auto 16px;
max-width: 300px;
width: 100%;
border: 2px solid #4CAF50; /* Добавляем зеленую обводку */
} }
@supports (-webkit-touch-callout: none) { .toggleButton {
/* Стили только для iOS устройств */ flex: 1;
.tokenPacks { padding: 8px 12px;
padding: 0 24px; /* Увеличить боковые отступы */ border: none;
padding-bottom: 100px; /* Добавить отступ снизу */ background: transparent;
-webkit-overflow-scrolling: touch; /* Улучшить скроллинг */ 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: 16px;
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%;
}
/* Контейнер для карточки со звездочкой */
.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;
} }
} }

View File

@ -1,14 +1,15 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import styles from './Profile.module.css'; import styles from './Profile.module.css';
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 customAnalyticsService from '../services/customAnalyticsService'; import customAnalyticsService from '../services/customAnalyticsService';
import TokenPacksList from '../components/tokens/TokenPacksList';
import { tokenPacks, TokenPack } from '../constants/tokenPacks'; import { tokenPacks, TokenPack } from '../constants/tokenPacks';
import { paymentService } from '../services/paymentService'; import { paymentService } from '../services/paymentService';
import NotificationModal from '../components/shared/NotificationModal'; import NotificationModal from '../components/shared/NotificationModal';
import { getTranslation } from '../constants/translations'; import { getTranslation } from '../constants/translations';
import OfferWallCard from '../components/offerwall/OfferWallCard';
import { images } from '../assets';
const Profile: React.FC = () => { const Profile: React.FC = () => {
const [stickersCount, setStickersCount] = useState<number>(0); const [stickersCount, setStickersCount] = useState<number>(0);
@ -17,6 +18,7 @@ const Profile: React.FC = () => {
const [showPaymentSuccessModal, setShowPaymentSuccessModal] = useState<boolean>(false); const [showPaymentSuccessModal, setShowPaymentSuccessModal] = useState<boolean>(false);
const [lastPurchasedPack, setLastPurchasedPack] = useState<TokenPack | null>(null); const [lastPurchasedPack, setLastPurchasedPack] = useState<TokenPack | null>(null);
const [userBalance, setUserBalance] = useState<number>(0); const [userBalance, setUserBalance] = useState<number>(0);
const [showRub, setShowRub] = useState<boolean>(true);
useEffect(() => { useEffect(() => {
fetchUserData(); 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 handleBuyPack = (packId: string) => {
const pack = tokenPacks.find(p => p.id === packId); const pack = tokenPacks.find(p => p.id === packId);
if (!pack) return; if (!pack) return;
@ -57,7 +77,7 @@ const Profile: React.FC = () => {
setLastPurchasedPack(pack); setLastPurchasedPack(pack);
paymentService.showBuyTokensPopup(pack, async (userData) => { const handlePaymentSuccess = async (userData: any = null) => {
if (userData) { if (userData) {
// Обновляем данные на основе полученной информации // Обновляем данные на основе полученной информации
if (userData.stickers_count !== undefined) { if (userData.stickers_count !== undefined) {
@ -70,19 +90,25 @@ const Profile: React.FC = () => {
setUserBalance(userData.balance); setUserBalance(userData.balance);
} }
// Показываем модальное окно с информацией об успешной оплате // Не показываем модальное окно после оплаты
setShowPaymentSuccessModal(true);
} else { } else {
// Если данные не получены, делаем запрос на получение данных пользователя // Если данные не получены, делаем запрос на получение данных пользователя
try { try {
await fetchUserData(); await fetchUserData();
// Показываем модальное окно с информацией об успешной оплате // Не показываем модальное окно после оплаты
setShowPaymentSuccessModal(true);
} catch (error) { } catch (error) {
console.error('Ошибка при обновлении данных пользователя:', error); console.error('Ошибка при обновлении данных пользователя:', error);
} }
} }
}); };
if (showRub) {
// Используем оплату рублями
paymentService.showRubPaymentPopup(pack.title, handlePaymentSuccess);
} else {
// Используем оплату звездами
paymentService.showBuyTokensPopup(pack, handlePaymentSuccess);
}
}; };
const handleClosePaymentSuccessModal = () => { const handleClosePaymentSuccessModal = () => {
@ -111,12 +137,143 @@ const Profile: React.FC = () => {
</div> </div>
</div> </div>
<TokenPacksList {/* Переключатель цен */}
onBuyPack={handleBuyPack} <div className={styles.priceToggle}>
className={styles.tokenPacks} <button
source="profile" className={`${styles.toggleButton} ${showRub ? styles.active : ''}`}
onClick={() => setShowRub(true)}
>
Цена за <span className={styles.rubSymbol}></span>
</button>
<button
className={`${styles.toggleButton} ${!showRub ? styles.active : ''}`}
onClick={() => setShowRub(false)}
>
Цена за <span className={styles.tokenSymbol}></span>
</button>
</div>
<div className={styles.offersWrapper}>
{/* Левая колонка */}
<div className={styles.leftColumn}>
{/* Стартовый набор (первая карточка) */}
<OfferWallCard
key={tokenPacks[0].id}
title={tokenPacks[0].title}
tokens={tokenPacks[0].tokens}
bonusTokens={tokenPacks[0].bonusTokens}
stickersCount={tokenPacks[0].stickersCount}
price={tokenPacks[0].price}
originalPrice={tokenPacks[0].originalPrice}
priceRub={tokenPacks[0].priceRub}
originalPriceRub={tokenPacks[0].originalPriceRub}
discountPercent={tokenPacks[0].discountPercent}
description={tokenPacks[0].description}
isPopular={tokenPacks[0].isPopular}
isBestValue={tokenPacks[0].isBestValue}
backgroundColor={getCardColor(tokenPacks[0].id)}
fullWidth={false}
showRub={showRub}
onBuy={() => handleBuyPack(tokenPacks[0].id)}
/> />
{/* Стикерный запас (третья карточка) */}
<div className={styles.cardWithStar}>
<OfferWallCard
key={tokenPacks[2].id}
title={tokenPacks[2].title}
tokens={tokenPacks[2].tokens}
bonusTokens={tokenPacks[2].bonusTokens}
stickersCount={tokenPacks[2].stickersCount}
price={tokenPacks[2].price}
originalPrice={tokenPacks[2].originalPrice}
priceRub={tokenPacks[2].priceRub}
originalPriceRub={tokenPacks[2].originalPriceRub}
discountPercent={tokenPacks[2].discountPercent}
description={tokenPacks[2].description}
isPopular={tokenPacks[2].isPopular}
isBestValue={tokenPacks[2].isBestValue}
backgroundColor={getCardColor(tokenPacks[2].id)}
fullWidth={false}
showRub={showRub}
onBuy={() => handleBuyPack(tokenPacks[2].id)}
/>
</div>
</div>
{/* Правая колонка */}
<div className={styles.rightColumn}>
{/* Стикерный энтузиаст (вторая карточка) */}
<OfferWallCard
key={tokenPacks[1].id}
title={tokenPacks[1].title}
tokens={tokenPacks[1].tokens}
bonusTokens={tokenPacks[1].bonusTokens}
stickersCount={tokenPacks[1].stickersCount}
price={tokenPacks[1].price}
originalPrice={tokenPacks[1].originalPrice}
priceRub={tokenPacks[1].priceRub}
originalPriceRub={tokenPacks[1].originalPriceRub}
discountPercent={tokenPacks[1].discountPercent}
description={tokenPacks[1].description}
isPopular={tokenPacks[1].isPopular}
isBestValue={tokenPacks[1].isBestValue}
backgroundColor={getCardColor(tokenPacks[1].id)}
fullWidth={false}
showRub={showRub}
onBuy={() => handleBuyPack(tokenPacks[1].id)}
/>
{/* Зеленая карточка с изображением (четвертая карточка) */}
<div className={styles.imageCard}>
<img src={images.emoShock} alt="Эмоция" className={styles.imageCardImg} />
</div>
</div>
{/* Стикерный магнат (пятая карточка, на всю ширину) */}
<OfferWallCard
key={tokenPacks[3].id}
title={tokenPacks[3].title}
tokens={tokenPacks[3].tokens}
bonusTokens={tokenPacks[3].bonusTokens}
stickersCount={tokenPacks[3].stickersCount}
price={tokenPacks[3].price}
originalPrice={tokenPacks[3].originalPrice}
priceRub={tokenPacks[3].priceRub}
originalPriceRub={tokenPacks[3].originalPriceRub}
discountPercent={tokenPacks[3].discountPercent}
description={tokenPacks[3].description}
isPopular={tokenPacks[3].isPopular}
isBestValue={tokenPacks[3].isBestValue}
backgroundColor={getCardColor(tokenPacks[3].id)}
fullWidth={true}
showRub={showRub}
onBuy={() => handleBuyPack(tokenPacks[3].id)}
/>
{/* Бог стикеров (шестая карточка, на всю ширину) */}
<OfferWallCard
key={tokenPacks[4].id}
title={tokenPacks[4].title}
tokens={tokenPacks[4].tokens}
bonusTokens={tokenPacks[4].bonusTokens}
stickersCount={tokenPacks[4].stickersCount}
price={tokenPacks[4].price}
originalPrice={tokenPacks[4].originalPrice}
priceRub={tokenPacks[4].priceRub}
originalPriceRub={tokenPacks[4].originalPriceRub}
discountPercent={tokenPacks[4].discountPercent}
description={tokenPacks[4].description}
isPopular={tokenPacks[4].isPopular}
isBestValue={tokenPacks[4].isBestValue}
backgroundColor={getCardColor(tokenPacks[4].id)}
fullWidth={true}
showRub={showRub}
bestValueLabel={true}
onBuy={() => handleBuyPack(tokenPacks[4].id)}
/>
</div>
{/* Модальное окно успешной оплаты */} {/* Модальное окно успешной оплаты */}
<NotificationModal <NotificationModal
isVisible={showPaymentSuccessModal} isVisible={showPaymentSuccessModal}

View File

@ -0,0 +1,285 @@
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import apiService from '../services/api';
import { Stage, Layer, Image as KonvaImage, Text as KonvaText } from 'react-konva';
import { editorFonts } from '../config/editorFonts';
import { editorStickers } from '../config/editorStickers';
type EditorObject =
| { type: 'text'; id: string; text: string; x: number; y: number; fontSize: number; fontFamily: string; fill: string; stroke: string; rotation: number }
| { type: 'sticker'; id: string; src: string; x: number; y: number; width: number; height: number; rotation: number };
const initialText = {
type: 'text' as const,
id: 'text1',
text: 'Текст',
x: 100,
y: 100,
fontSize: 48,
fontFamily: editorFonts[0].family,
fill: '#fff',
stroke: '#000',
rotation: 0
};
const StickerEditorScreen: React.FC = () => {
const { stickerId } = useParams<{ stickerId: string }>();
const navigate = useNavigate();
const [stickerUrl, setStickerUrl] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [stickerImg, setStickerImg] = useState<HTMLImageElement | null>(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<EditorObject[]>([]);
const [undoStack, setUndoStack] = useState<EditorObject[][]>([]);
const [redoStack, setRedoStack] = useState<EditorObject[][]>([]);
// Загружаем изображение для 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<string>((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 (
<div style={{
width: '100vw',
height: '100vh',
background: '#222',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff'
}}>
<span>Загрузка стикера...</span>
</div>
);
}
if (error) {
return (
<div style={{
width: '100vw',
height: '100vh',
background: '#222',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
flexDirection: 'column'
}}>
<span>{error}</span>
<button style={{
marginTop: 24,
padding: '12px 24px',
borderRadius: 8,
border: 'none',
background: '#444',
color: '#fff',
fontSize: 16
}} onClick={() => navigate(-1)}>Назад</button>
</div>
);
}
return (
<div style={{
width: '100vw',
height: '100vh',
background: '#222',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
}}>
<h2 style={{ color: '#fff', marginBottom: 16 }}>Редактор стикеров (MVP)</h2>
<div
style={{
width: 512,
height: 512,
background: '#fff0',
borderRadius: 16,
boxShadow: '0 2px 16px rgba(0,0,0,0.15)',
marginBottom: 24,
overflow: 'hidden',
touchAction: 'none'
}}
>
<Stage width={512} height={512}>
<Layer>
{stickerImg && (
<KonvaImage
image={stickerImg}
x={0}
y={0}
width={512}
height={512}
listening={false}
perfectDrawEnabled={false}
preventDefault={false}
/>
)}
{objects.map(obj =>
obj.type === 'text' ? (
<KonvaText
key={obj.id}
text={obj.text}
x={obj.x}
y={obj.y}
fontSize={obj.fontSize}
fontFamily={obj.fontFamily}
fill={obj.fill}
stroke={obj.stroke}
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);
}}
/>
) : (
<KonvaImage
key={obj.id}
image={(() => {
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);
}}
/>
)
)}
</Layer>
</Stage>
</div>
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
<button onClick={handleAddText} style={{ padding: '8px 16px', borderRadius: 8 }}>Добавить текст</button>
<button onClick={handleUndo} disabled={undoStack.length === 0} style={{ padding: '8px 16px', borderRadius: 8 }}>Undo</button>
<button onClick={handleRedo} disabled={redoStack.length === 0} style={{ padding: '8px 16px', borderRadius: 8 }}>Redo</button>
<div>
<span style={{ color: '#fff', fontSize: 14, marginRight: 8 }}>Эмодзи:</span>
{editorStickers.map(st => (
<button key={st.name} onClick={() => handleAddSticker(st.src)} style={{ marginRight: 4, borderRadius: 8, padding: 4 }}>
<img src={st.src} alt={st.name} width={24} height={24} />
</button>
))}
</div>
</div>
<span style={{ color: '#fff', opacity: 0.7 }}>MVP: drag, undo/redo, добавление текста и эмодзи</span>
</div>
);
};
export default StickerEditorScreen;

View File

@ -9,6 +9,23 @@
height: 100%; /* Устанавливаем высоту */ 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 { .header {
padding: var(--spacing-small) 0; padding: var(--spacing-small) 0;
text-align: center; text-align: center;

View File

@ -1,79 +1,236 @@
.stepsContainer { .root {
display: flex; display: flex;
flex-direction: column; 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; display: flex;
align-items: flex-start; flex-direction: column;
gap: var(--spacing-medium); align-items: stretch;
animation: fadeIn 0.3s ease-out;
animation-fill-mode: both;
} }
.step:nth-child(1) { .image {
animation-delay: 0.1s; 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) { .stepsBlock {
animation-delay: 0.2s; margin: 0 0 16px 0;
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
z-index: 2;
} }
.step:nth-child(3) { .stepRow {
animation-delay: 0.3s; display: flex;
align-items: center;
gap: 12px;
} }
.stepNumber { .stepBadge {
width: 32px; background: #eafeea;
height: 32px; border-radius: 24px;
border-radius: 50%; 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; display: flex;
justify-content: center; justify-content: center;
align-items: 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-weight: 700;
font-size: 16px; border: none;
color: white; 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; flex-shrink: 0;
} }
.step:nth-child(1) .stepNumber { .checkbox:checked + .fakeCheckbox {
background-color: var(--color-primary); background: #16b100;
border-color: #16b100;
} }
.step:nth-child(2) .stepNumber { .checkbox:checked + .fakeCheckbox::after {
background-color: var(--color-secondary); 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 { .checkboxText {
background-color: var(--color-accent2); display: inline;
line-height: 1.3;
color: #222;
font-size: 13px;
margin-left: 2px;
margin-right: 0;
word-break: break-word;
} }
.stepContent { .link {
flex: 1; color: #1976d2;
} text-decoration: underline;
cursor: pointer;
.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;
}
} }

View File

@ -1,82 +1,86 @@
import React, { useEffect } from 'react'; import React, { useState } from "react";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import OnboardingLayout from '../../components/shared/OnboardingLayout'; import styles from "./OnboardingHowTo.module.css";
import styles from './OnboardingHowTo.module.css'; import { images } from "../../assets";
import customAnalyticsService from '../../services/customAnalyticsService'; import { getTranslation } from "../../constants/translations";
import { getCurrentUserId } from '../../constants/user'; import { Link } from "react-router-dom";
import { getTranslation } from '../../constants/translations';
const OnboardingHowTo: React.FC = () => { const OnboardingHowTo: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [checked, setChecked] = useState(false);
useEffect(() => {
// Отслеживаем событие открытия экрана инструкции
customAnalyticsService.trackEvent({
telegram_id: getCurrentUserId(),
event_category: 'navigation',
event_name: 'view_onboarding_how_to'
});
}, []);
const handleNext = () => { const handleNext = () => {
// Отслеживаем событие нажатия кнопки "Далее" if (checked) {
customAnalyticsService.trackEvent({ // Устанавливаем флаг, что пользователь принял условия
telegram_id: getCurrentUserId(), localStorage.setItem('hasAcceptedTerms', 'true');
event_category: 'onboarding', navigate("/onboarding/sticker-packs");
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('/');
}; };
return ( return (
<OnboardingLayout <div className={styles.root}>
title={getTranslation('how_to_title')} <div className={styles.imageBlock}>
currentStep={2} <img
totalSteps={3} src={images.onboarding_img2}
primaryButtonText={getTranslation('next')} alt="Онбординг шаги"
secondaryButtonText={getTranslation('skip')} className={styles.image}
onPrimaryClick={handleNext} draggable={false}
onSecondaryClick={handleSkip} />
</div>
<div className={styles.contentWrapper}>
<div className={styles.title}>Нет ничего проще!</div>
<ul className={styles.list}>
<li>Создавайте стикеры в три клика</li>
<li>Собирайте из них наборы</li>
<li>Публикуйте свои коллекции в телеграмм</li>
</ul>
</div>
<div className={styles.bottomContent}>
<div className={styles.progress}>
<span className={styles.dot}></span>
<span className={`${styles.dot} ${styles.dotActive}`}></span>
<span className={styles.dot}></span>
</div>
<button
className={styles.button}
onClick={handleNext}
disabled={!checked}
> >
<div className={styles.stepsContainer}> Дальше
<div className={styles.step}> </button>
<div className={styles.stepNumber}>1</div> <div className={styles.checkboxRow}>
<div className={styles.stepContent}> <label className={styles.checkboxLabel}>
<h3 className={styles.stepTitle}>{getTranslation('upload_photo_title')}</h3> <input
<p className={styles.stepDescription}>{getTranslation('upload_photo_description')}</p> type="checkbox"
</div> checked={checked}
</div> onChange={() => setChecked((v) => !v)}
className={styles.checkbox}
<div className={styles.step}> />
<div className={styles.stepNumber}>2</div> <span className={styles.fakeCheckbox}></span>
<div className={styles.stepContent}> <span className={styles.checkboxText}>
<h3 className={styles.stepTitle}>{getTranslation('choose_style_title')}</h3> Нажимая на кнопку "Дальше" я соглашаюсь с условиями{" "}
<p className={styles.stepDescription}>{getTranslation('choose_style_description')}</p> <a
</div> href="https://telegra.ph/Polzovatelskoe-soglashenie-03-19-13"
</div> className={styles.link}
target="_blank"
<div className={styles.step}> rel="noopener noreferrer"
<div className={styles.stepNumber}>3</div> >
<div className={styles.stepContent}> Пользовательского соглашения
<h3 className={styles.stepTitle}>{getTranslation('create_sticker_title')}</h3> </a>{" "}
<p className={styles.stepDescription}>{getTranslation('create_sticker_description')}</p> и{" "}
<a
href="https://telegra.ph/Politika-konfidencialnosti-03-19-10"
className={styles.link}
target="_blank"
rel="noopener noreferrer"
>
Политики конфиденциальности
</a>
</span>
</label>
</div> </div>
</div> </div>
</div> </div>
</OnboardingLayout>
); );
}; };

View File

@ -66,14 +66,239 @@
line-height: 1.4; line-height: 1.4;
} }
@media (max-width: 480px) { /* Контейнер для хедера */
.stepsContainer { .headerContainer {
gap: var(--spacing-small); 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;
} }
.stepNumber { /* Кнопка "Пропустить" */
width: 28px; .skipButton {
height: 28px; position: absolute;
top: 12px;
right: 16px;
font-size: 14px; 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;
}
.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; /* Чтобы звездочка не перехватывала клики */
}

View File

@ -1,13 +1,50 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import OnboardingLayout from '../../components/shared/OnboardingLayout'; import OnboardingLayout from '../../components/shared/OnboardingLayout';
import styles from './OnboardingStickerPacks.module.css'; import styles from './OnboardingStickerPacks.module.css';
import customAnalyticsService from '../../services/customAnalyticsService'; import customAnalyticsService from '../../services/customAnalyticsService';
import { getCurrentUserId } from '../../constants/user'; import { getCurrentUserId } from '../../constants/user';
import { getTranslation } from '../../constants/translations'; 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 OnboardingStickerPacks: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [showRub, setShowRub] = useState(true);
const leftColumnRef = useRef<HTMLDivElement>(null);
const rightColumnRef = useRef<HTMLDivElement>(null);
const greenCardRef = useRef<HTMLDivElement>(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(() => { useEffect(() => {
// Отслеживаем событие открытия экрана стикерпаков // Отслеживаем событие открытия экрана стикерпаков
@ -29,52 +66,189 @@ const OnboardingStickerPacks: React.FC = () => {
// Устанавливаем флаг, что пользователь видел онбординг // Устанавливаем флаг, что пользователь видел онбординг
localStorage.setItem('hasSeenOnboarding', 'true'); localStorage.setItem('hasSeenOnboarding', 'true');
// Проверяем, принял ли пользователь уже условия использования // Переходим на главный экран
const hasAcceptedTerms = localStorage.getItem('hasAcceptedTerms') === 'true'; // Пользователь уже принял условия на экране How-to
if (hasAcceptedTerms) {
// Если уже принял условия, переходим на главный экран
navigate('/'); navigate('/');
} else { };
// Если еще не принял условия, переходим на экран с условиями
navigate('/onboarding/terms'); // Функция для получения цвета карточки по 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 ( return (
<OnboardingLayout <OnboardingLayout
title={getTranslation('sticker_packs_title')} title="Пакеты токенов" /* Добавляем обратно title, хотя он не будет отображаться */
currentStep={3} currentStep={3}
totalSteps={3} totalSteps={3}
primaryButtonText={getTranslation('start')} primaryButtonText={getTranslation('start')}
secondaryButtonText={getTranslation('skip')} secondaryButtonText={getTranslation('skip')}
onPrimaryClick={handleStart} onPrimaryClick={handleStart}
onSecondaryClick={handleStart} onSecondaryClick={handleStart}
showProgressDots={false}
showButtons={false}
> >
<div className={styles.stepsContainer}> {/* Кнопка "Пропустить" в правом верхнем углу */}
<div className={styles.step}> <button className={styles.skipButton} onClick={handleStart}>
<div className={styles.stepNumber}>1</div> Пропустить
<div className={styles.stepContent}> </button>
<h3 className={styles.stepTitle}>{getTranslation('select_stickers_title')}</h3> {/* Фиксированный хедер */}
<p className={styles.stepDescription}>{getTranslation('select_stickers_description')}</p> <div className={styles.headerContainer}>
<h1 className={styles.headerTitle}>Пакеты токенов</h1>
{/* Переключатель цен */}
<div className={styles.priceToggle}>
<button
className={`${styles.toggleButton} ${showRub ? styles.active : ''}`}
onClick={() => setShowRub(true)}
>
Цена за <span className={styles.rubSymbol}></span>
</button>
<button
className={`${styles.toggleButton} ${!showRub ? styles.active : ''}`}
onClick={() => setShowRub(false)}
>
Цена за <span className={styles.tokenSymbol}></span>
</button>
</div> </div>
</div> </div>
<div className={styles.step}> <div className={styles.offersWrapper}>
<div className={styles.stepNumber}>2</div> {/* Левая колонка */}
<div className={styles.stepContent}> <div className={styles.leftColumn} ref={leftColumnRef}>
<h3 className={styles.stepTitle}>{getTranslation('organize_pack_title')}</h3> {/* Стартовый набор (первая карточка) */}
<p className={styles.stepDescription}>{getTranslation('organize_pack_description')}</p> <OfferWallCard
key={tokenPacks[0].id}
title={tokenPacks[0].title}
tokens={tokenPacks[0].tokens}
bonusTokens={tokenPacks[0].bonusTokens}
stickersCount={tokenPacks[0].stickersCount}
price={tokenPacks[0].price}
originalPrice={tokenPacks[0].originalPrice}
priceRub={tokenPacks[0].priceRub}
originalPriceRub={tokenPacks[0].originalPriceRub}
discountPercent={tokenPacks[0].discountPercent}
description={tokenPacks[0].description}
isPopular={tokenPacks[0].isPopular}
isBestValue={tokenPacks[0].isBestValue}
backgroundColor={getCardColor(tokenPacks[0].id)}
fullWidth={false}
showRub={showRub}
onBuy={() => handleBuyPack(tokenPacks[0])}
/>
{/* Стикерный запас (третья карточка) */}
<div className={styles.cardWithStar}>
<OfferWallCard
key={tokenPacks[2].id}
title={tokenPacks[2].title}
tokens={tokenPacks[2].tokens}
bonusTokens={tokenPacks[2].bonusTokens}
stickersCount={tokenPacks[2].stickersCount}
price={tokenPacks[2].price}
originalPrice={tokenPacks[2].originalPrice}
priceRub={tokenPacks[2].priceRub}
originalPriceRub={tokenPacks[2].originalPriceRub}
discountPercent={tokenPacks[2].discountPercent}
description={tokenPacks[2].description}
isPopular={tokenPacks[2].isPopular}
isBestValue={tokenPacks[2].isBestValue}
backgroundColor={getCardColor(tokenPacks[2].id)}
fullWidth={false}
showRub={showRub}
onBuy={() => handleBuyPack(tokenPacks[2])}
/>
</div> </div>
</div> </div>
<div className={styles.step}> {/* Правая колонка */}
<div className={styles.stepNumber}>3</div> <div className={styles.rightColumn} ref={rightColumnRef}>
<div className={styles.stepContent}> {/* Стикерный энтузиаст (вторая карточка) */}
<h3 className={styles.stepTitle}>{getTranslation('publish_title')}</h3> <OfferWallCard
<p className={styles.stepDescription}>{getTranslation('publish_description')}</p> key={tokenPacks[1].id}
title={tokenPacks[1].title}
tokens={tokenPacks[1].tokens}
bonusTokens={tokenPacks[1].bonusTokens}
stickersCount={tokenPacks[1].stickersCount}
price={tokenPacks[1].price}
originalPrice={tokenPacks[1].originalPrice}
priceRub={tokenPacks[1].priceRub}
originalPriceRub={tokenPacks[1].originalPriceRub}
discountPercent={tokenPacks[1].discountPercent}
description={tokenPacks[1].description}
isPopular={tokenPacks[1].isPopular}
isBestValue={tokenPacks[1].isBestValue}
backgroundColor={getCardColor(tokenPacks[1].id)}
fullWidth={false}
showRub={showRub}
onBuy={() => handleBuyPack(tokenPacks[1])}
/>
{/* Зеленая карточка с изображением (четвертая карточка) */}
<div className={styles.imageCard} ref={greenCardRef}>
<img src={images.emoShock} alt="Эмоция" className={styles.imageCardImg} />
</div> </div>
</div> </div>
{/* Стикерный магнат (пятая карточка, на всю ширину) */}
<OfferWallCard
key={tokenPacks[3].id}
title={tokenPacks[3].title}
tokens={tokenPacks[3].tokens}
bonusTokens={tokenPacks[3].bonusTokens}
stickersCount={tokenPacks[3].stickersCount}
price={tokenPacks[3].price}
originalPrice={tokenPacks[3].originalPrice}
priceRub={tokenPacks[3].priceRub}
originalPriceRub={tokenPacks[3].originalPriceRub}
discountPercent={tokenPacks[3].discountPercent}
description={tokenPacks[3].description}
isPopular={tokenPacks[3].isPopular}
isBestValue={tokenPacks[3].isBestValue}
backgroundColor={getCardColor(tokenPacks[3].id)}
fullWidth={true}
showRub={showRub}
onBuy={() => handleBuyPack(tokenPacks[3])}
/>
{/* Бог стикеров (шестая карточка, на всю ширину) */}
<OfferWallCard
key={tokenPacks[4].id}
title={tokenPacks[4].title}
tokens={tokenPacks[4].tokens}
bonusTokens={tokenPacks[4].bonusTokens}
stickersCount={tokenPacks[4].stickersCount}
price={tokenPacks[4].price}
originalPrice={tokenPacks[4].originalPrice}
priceRub={tokenPacks[4].priceRub}
originalPriceRub={tokenPacks[4].originalPriceRub}
discountPercent={tokenPacks[4].discountPercent}
description={tokenPacks[4].description}
isPopular={tokenPacks[4].isPopular}
isBestValue={tokenPacks[4].isBestValue}
backgroundColor={getCardColor(tokenPacks[4].id)}
fullWidth={true}
showRub={showRub}
bestValueLabel={true}
onBuy={() => handleBuyPack(tokenPacks[4])}
/>
</div> </div>
</OnboardingLayout> </OnboardingLayout>
); );

View File

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

View File

@ -1,16 +1,15 @@
import React, { useEffect } from 'react'; import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import OnboardingLayout from '../../components/shared/OnboardingLayout';
import { images } from '../../assets'; import { images } from '../../assets';
import customAnalyticsService from '../../services/customAnalyticsService'; import customAnalyticsService from '../../services/customAnalyticsService';
import { getCurrentUserId } from '../../constants/user'; import { getCurrentUserId } from '../../constants/user';
import { getTranslation } from '../../constants/translations'; import { getTranslation } from '../../constants/translations';
import styles from './OnboardingWelcome.module.css';
const OnboardingWelcome: React.FC = () => { const OnboardingWelcome: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => { useEffect(() => {
// Отслеживаем событие открытия экрана приветствия
customAnalyticsService.trackEvent({ customAnalyticsService.trackEvent({
telegram_id: getCurrentUserId(), telegram_id: getCurrentUserId(),
event_category: 'navigation', event_category: 'navigation',
@ -19,40 +18,47 @@ const OnboardingWelcome: React.FC = () => {
}, []); }, []);
const handleNext = () => { const handleNext = () => {
// Отслеживаем событие нажатия кнопки "Далее"
customAnalyticsService.trackEvent({ customAnalyticsService.trackEvent({
telegram_id: getCurrentUserId(), telegram_id: getCurrentUserId(),
event_category: 'onboarding', event_category: 'onboarding',
event_name: 'welcome_next_click' event_name: 'welcome_next_click'
}); });
navigate('/onboarding/how-to'); 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 ( return (
<OnboardingLayout <div className={styles.root}>
title={getTranslation('welcome_title')} <div className={styles.imageBlock}>
image={images.onboard1} <img
description={getTranslation('welcome_description')} src={images.onboardingWelcome}
currentStep={1} alt="Онбординг"
totalSteps={3} className={styles.image}
primaryButtonText={getTranslation('next')} draggable={false}
secondaryButtonText={getTranslation('skip')}
onPrimaryClick={handleNext}
onSecondaryClick={handleSkip}
/> />
</div>
<div className={styles.contentWrapper}>
<div className={styles.textContent}>
<h1 className={styles.title}>
Добро пожаловать<br />в Sticker Generator
</h1>
<div className={styles.description}>
Создавайте уникальные стикеры<br />
из фотографий с помощью<br />
искусственного интеллекта
</div>
</div>
<div className={styles.bottomContent}>
<div className={styles.progress}>
<span className={`${styles.dot} ${styles.dotActive}`}></span>
<span className={styles.dot}></span>
<span className={styles.dot}></span>
</div>
<button className={styles.button} onClick={handleNext}>
Дальше
</button>
</div>
</div>
</div>
); );
}; };

View File

@ -3,6 +3,15 @@ import apiService from '../services/api';
import { getCurrentUserId } from '../constants/user'; import { getCurrentUserId } from '../constants/user';
import customAnalyticsService from './customAnalyticsService'; import customAnalyticsService from './customAnalyticsService';
// Словарь для маппинга названий пакетов к значениям description для API
const packTitleToDescription: Record<string, string> = {
'Стартовый набор': 'стартовый набор',
'Стикерный энтузиаст': 'стикерный энтузиаст',
'Стикерный запас': 'стикерный запас',
'Стикерный магнат': 'стикерный магнат',
'Бог стикеров': 'бог стикеров'
};
export const paymentService = { export const paymentService = {
showBuyTokensPopup: async (pack: TokenPack, onSuccess?: (userData?: any) => void) => { showBuyTokensPopup: async (pack: TokenPack, onSuccess?: (userData?: any) => void) => {
// Проверяем наличие Telegram WebApp // Проверяем наличие Telegram WebApp
@ -66,5 +75,76 @@ export const paymentService = {
console.error('Ошибка при создании инвойса:', error); console.error('Ошибка при создании инвойса:', error);
webApp.showAlert('Произошла ошибка при создании платежа. Пожалуйста, попробуйте позже.'); 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('Произошла ошибка при создании платежа. Пожалуйста, попробуйте позже.');
}
} }
}; };

View File

@ -4,6 +4,11 @@ import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
server: {
host: '0.0.0.0', // Делаем сервер доступным по локальной сети
port: 5173, // Фиксируем порт
strictPort: true, // Не пытаемся использовать другой порт, если 5173 занят
},
css: { css: {
modules: { modules: {
localsConvention: 'camelCase', localsConvention: 'camelCase',

View File

@ -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 — максимально близок к мобильным привычкам.