рупное обновление: добавлен редактор стикеров, offerwall, обновлены UI компоненты
- ✨ обавлен редактор стикеров (StickerEditorScreen.tsx) - 💰 обавлена offerwall функциональность - 🎨 обавлены Figma дизайн-файлы - 🔧 обавлены конфигурации редактора (editorFonts.ts, editorStickers.ts) - 🎯 бновлены компоненты: Gallery, Profile, Header, TokenPacks - 📱 бновлены onboarding экраны и стили - 📦 бновлены зависимости проекта - ��️ обавлены новые изображения для onboarding - 📋 обавлен план реализации редактора стикеров
7
figma/Frame 1340414245.svg
Normal 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 |
4
figma/Frame 1340414252.svg
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
figma/Frame 1340414261.png
Normal file
|
After Width: | Height: | Size: 61 KiB |
31
figma/Frame 1340414263.svg
Normal file
|
After Width: | Height: | Size: 3.7 MiB |
3
figma/Home Indicator.svg
Normal 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
BIN
figma/onboarding_img1.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
figma/onboarding_img1.webp
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
figma/onboarding_img2.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
91
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"user_id": 296487847,
|
||||||
|
"description": "стартовый набор"
|
||||||
|
}
|
||||||
14
src/App.tsx
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
BIN
src/assets/onboarding_img1.webp
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
src/assets/onboarding_img2.webp
Normal file
|
After Width: | Height: | Size: 40 KiB |
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
409
src/components/offerwall/OfferWallCard.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
176
src/components/offerwall/OfferWallCard.tsx
Normal 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;
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
@ -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'
|
||||||
|
}
|
||||||
|
// Добавляйте новые шрифты по аналогии
|
||||||
|
];
|
||||||
24
src/config/editorStickers.ts
Normal 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>'
|
||||||
|
}
|
||||||
|
// Добавляйте новые стикеры по аналогии
|
||||||
|
];
|
||||||
@ -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: 'Для профессионалов и настоящих ценителей. Неограниченные возможности для творчества с максимальной выгодой.'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
285
src/screens/StickerEditorScreen.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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; /* Чтобы звездочка не перехватывала клики */
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
203
src/screens/onboarding/OnboardingWelcome.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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('Произошла ошибка при создании платежа. Пожалуйста, попробуйте позже.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
91
Редактор_стикеров_план_реализации.md
Normal 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 — максимально близок к мобильным привычкам.
|
||||||