3 Commits
Author SHA1 Message Date
IgorVolochay bff869fa9d Update GitHub CI piplines 2026-09-01 14:55:02 +03:00
IgorVolochay 008dd1c29e Update frontend for new backend 2026-09-01 14:31:52 +03:00
Igor VolochayandGitHub bdf3d5aec1 Merge pull request #3 from IgorVolochay/app
Merge app and frontend
2026-09-01 12:00:36 +03:00
26 changed files with 1473 additions and 287 deletions
@@ -1,4 +1,4 @@
name: app-actions
name: Backend CI
on:
workflow_dispatch:
@@ -6,19 +6,24 @@ on:
paths:
- 'app/**'
branches:
- main
- app
- prebuild
- main
pull_request:
paths:
- 'app/**'
branches:
- app
- prebuild
- main
jobs:
mypy:
name: Backend Lint (mypy)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
@@ -37,19 +42,20 @@ jobs:
run: mypy --ignore-missing-imports ./app
pytest:
name: Backend Tests (pytest)
runs-on: ubuntu-latest
env:
MONGO_HOST: "127.0.0.1"
MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }}
MONGO_USER: ${{ secrets.MONGO_USER }}
MONGO_PASS: ${{ secrets.MONGO_PASS }}
MONGO_USER: ${{ secrets.MONGO_USER || 'admin' }}
MONGO_PASS: ${{ secrets.MONGO_PASS || 'secret' }}
RABBIT_HOST: "127.0.0.1"
RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }}
RABBIT_USER: ${{ secrets.RABBIT_USER }}
RABBIT_PASS: ${{ secrets.RABBIT_PASS }}
RABBIT_USER: ${{ secrets.RABBIT_USER || 'guest' }}
RABBIT_PASS: ${{ secrets.RABBIT_PASS || 'guest' }}
DEV_MODE: ${{ secrets.DEV_MODE || 'true' }}
steps:
- name: Checkout
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
@@ -97,17 +103,3 @@ jobs:
- name: Run pytest
run: pytest -vs
docker-build:
runs-on: ubuntu-latest
needs: [mypy, pytest]
if: github.ref == 'refs/heads/main' || github.event_name == 'workflow_dispatch'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build backend image
run: docker build -f app/dockerfile.app -t tort-backend:ci ./app
- name: Build bot image
run: docker build -f app/dockerfile.bot -t tort-tg-bot:ci ./app
+166
View File
@@ -0,0 +1,166 @@
name: Publish to GHCR (Main)
on:
workflow_dispatch:
push:
branches:
- main
permissions:
contents: read
packages: write
jobs:
frontend-test-build:
name: Frontend (Test & Build)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
- name: Run frontend tests
working-directory: ./frontend
run: npm test -- --watchAll=false
- name: Build React app
working-directory: ./frontend
run: npm run build
backend-mypy:
name: Backend Lint (mypy)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: app/requirements.txt
- name: Install dependencies
run: |
pip install mypy
pip install -r app/requirements.txt
- name: Run mypy
run: mypy --ignore-missing-imports ./app
backend-pytest:
name: Backend Tests (pytest)
runs-on: ubuntu-latest
env:
MONGO_HOST: "127.0.0.1"
MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }}
MONGO_USER: ${{ secrets.MONGO_USER || 'admin' }}
MONGO_PASS: ${{ secrets.MONGO_PASS || 'secret' }}
RABBIT_HOST: "127.0.0.1"
RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }}
RABBIT_USER: ${{ secrets.RABBIT_USER || 'guest' }}
RABBIT_PASS: ${{ secrets.RABBIT_PASS || 'guest' }}
DEV_MODE: ${{ secrets.DEV_MODE || 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: app/requirements.txt
- name: Start MongoDB
run: |
docker run -d --name mongodb \
-p "${MONGO_PORT}:27017" \
-e "MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}" \
-e "MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS}" \
mongodb/mongodb-community-server
for i in $(seq 1 30); do
docker exec mongodb mongosh \
--username "${MONGO_USER}" --password "${MONGO_PASS}" \
--eval "db.runCommand({ping:1})" && break
sleep 1
done
- name: Start RabbitMQ
run: |
docker run -d --name rabbitmq \
-p "${RABBIT_PORT}:5672" \
-e "RABBITMQ_DEFAULT_USER=${RABBIT_USER}" \
-e "RABBITMQ_DEFAULT_PASS=${RABBIT_PASS}" \
rabbitmq:3.13-alpine
for i in $(seq 1 30); do
docker exec rabbitmq rabbitmq-diagnostics -q ping && break
sleep 1
done
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest==8.3.4 pytest-asyncio==0.25.3 httpx==0.28.1
pip install -r app/requirements.txt
- name: Setup moderated base cards
working-directory: ./app/tools
run: python3 _add_base_cards.py -a 2 -f data/base_cards.json
- name: Run pytest
run: pytest -vs
docker-publish-ghcr:
name: Build & Push Images to GHCR
runs-on: ubuntu-latest
needs: [frontend-test-build, backend-mypy, backend-pytest]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set lowercase repository owner
id: repo_owner
run: echo "OWNER_LC=$(echo '${{ github.repository_owner }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_ENV"
- name: Build and push Backend image
uses: docker/build-push-action@v6
with:
context: ./app
file: ./app/dockerfile.app
push: true
tags: |
ghcr.io/${{ env.OWNER_LC }}/tort-backend:latest
ghcr.io/${{ env.OWNER_LC }}/tort-backend:${{ github.sha }}
- name: Build and push Bot image
uses: docker/build-push-action@v6
with:
context: ./app
file: ./app/dockerfile.bot
push: true
tags: |
ghcr.io/${{ env.OWNER_LC }}/tort-tg-bot:latest
ghcr.io/${{ env.OWNER_LC }}/tort-tg-bot:${{ github.sha }}
+45
View File
@@ -0,0 +1,45 @@
name: Frontend CI
on:
workflow_dispatch:
push:
paths:
- 'frontend/**'
branches:
- frontend
- prebuild
- main
pull_request:
paths:
- 'frontend/**'
branches:
- frontend
- prebuild
- main
jobs:
frontend-test-build:
name: React Test & Build
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
- name: Run frontend tests
working-directory: ./frontend
run: npm test -- --watchAll=false
- name: Build React app
working-directory: ./frontend
run: npm run build
+134
View File
@@ -0,0 +1,134 @@
name: Prebuild CI & Docker Test Build
on:
workflow_dispatch:
push:
branches:
- prebuild
pull_request:
branches:
- prebuild
jobs:
frontend-test-build:
name: Frontend (Test & Build)
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: ./frontend
run: npm ci
- name: Run frontend tests
working-directory: ./frontend
run: npm test -- --watchAll=false
- name: Build React app
working-directory: ./frontend
run: npm run build
backend-mypy:
name: Backend Lint (mypy)
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: app/requirements.txt
- name: Install dependencies
run: |
pip install mypy
pip install -r app/requirements.txt
- name: Run mypy
run: mypy --ignore-missing-imports ./app
backend-pytest:
name: Backend Tests (pytest)
runs-on: ubuntu-latest
env:
MONGO_HOST: "127.0.0.1"
MONGO_PORT: ${{ secrets.MONGO_PORT || '27017' }}
MONGO_USER: ${{ secrets.MONGO_USER || 'admin' }}
MONGO_PASS: ${{ secrets.MONGO_PASS || 'secret' }}
RABBIT_HOST: "127.0.0.1"
RABBIT_PORT: ${{ secrets.RABBIT_PORT || '5672' }}
RABBIT_USER: ${{ secrets.RABBIT_USER || 'guest' }}
RABBIT_PASS: ${{ secrets.RABBIT_PASS || 'guest' }}
DEV_MODE: ${{ secrets.DEV_MODE || 'true' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: app/requirements.txt
- name: Start MongoDB
run: |
docker run -d --name mongodb \
-p "${MONGO_PORT}:27017" \
-e "MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}" \
-e "MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASS}" \
mongodb/mongodb-community-server
for i in $(seq 1 30); do
docker exec mongodb mongosh \
--username "${MONGO_USER}" --password "${MONGO_PASS}" \
--eval "db.runCommand({ping:1})" && break
sleep 1
done
- name: Start RabbitMQ
run: |
docker run -d --name rabbitmq \
-p "${RABBIT_PORT}:5672" \
-e "RABBITMQ_DEFAULT_USER=${RABBIT_USER}" \
-e "RABBITMQ_DEFAULT_PASS=${RABBIT_PASS}" \
rabbitmq:3.13-alpine
for i in $(seq 1 30); do
docker exec rabbitmq rabbitmq-diagnostics -q ping && break
sleep 1
done
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install pytest==8.3.4 pytest-asyncio==0.25.3 httpx==0.28.1
pip install -r app/requirements.txt
- name: Setup moderated base cards
working-directory: ./app/tools
run: python3 _add_base_cards.py -a 2 -f data/base_cards.json
- name: Run pytest
run: pytest -vs
docker-test-build:
name: Docker Images Test Build (No Push)
runs-on: ubuntu-latest
needs: [frontend-test-build, backend-mypy, backend-pytest]
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Build Docker images (dry-run test via docker compose)
run: docker compose build
+1
View File
@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#070711" />
<meta name="description" content="This OR That — выбирай один из двух вариантов и смотри, что выбрали другие!" />
<meta name="referrer" content="no-referrer" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+6 -2
View File
@@ -5,8 +5,10 @@
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
height: 48px;
padding-top: max(var(--space-sm), env(safe-area-inset-top));
height: calc(48px + max(0px, env(safe-area-inset-top)));
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
}
.app-logo {
@@ -15,6 +17,7 @@
font-weight: 700;
color: var(--color-text);
letter-spacing: -0.3px;
user-select: none;
}
.app-logo-or {
@@ -25,6 +28,7 @@
border-radius: 6px;
font-size: 12px;
vertical-align: middle;
box-shadow: 0 0 10px rgba(124, 58, 237, 0.3);
}
/* Hamburger menu */
@@ -41,7 +45,7 @@
}
.menu-toggle:active {
background: rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.08);
}
.menu-toggle-line {
+6 -1
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { AppProvider, useApp } from './context/AppContext';
import LoadingScreen from './components/common/LoadingScreen';
import BannedScreen from './components/common/BannedScreen';
import Toast from './components/common/Toast';
import CardPair from './components/CardPair/CardPair';
import BottomBar from './components/BottomBar/BottomBar';
@@ -9,7 +10,11 @@ import MenuPanel from './components/Menu/MenuPanel';
import './App.css';
function AppContent() {
const { isLoading, error, openMenu, toast } = useApp();
const { isLoading, error, isBanned, handleRetryAfterBan, openMenu, toast } = useApp();
if (isBanned) {
return <BannedScreen onRetry={handleRetryAfterBan} />;
}
if (isLoading) {
return <LoadingScreen />;
+12 -5
View File
@@ -1,8 +1,15 @@
import { render, screen } from '@testing-library/react';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
test('renders app without crashing', async () => {
const container = document.createElement('div');
document.body.appendChild(container);
await act(async () => {
const root = createRoot(container);
root.render(<App />);
});
expect(container.innerHTML).toBeTruthy();
});
+13 -15
View File
@@ -2,8 +2,9 @@
display: flex;
align-items: center;
justify-content: space-around;
height: var(--bar-height);
min-height: var(--bar-height);
padding: 0 var(--space-lg);
padding-bottom: max(0px, env(safe-area-inset-bottom));
background: var(--color-bg-elevated);
border-top: 1px solid rgba(255, 255, 255, 0.06);
flex-shrink: 0;
@@ -13,7 +14,7 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
gap: 3px;
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
transition: color var(--duration-fast) var(--ease-smooth),
@@ -21,18 +22,19 @@
color: var(--color-muted);
}
.bar-btn:active {
.bar-btn:active:not(:disabled) {
transform: scale(0.92);
}
.bar-btn--disabled {
opacity: 0.4;
pointer-events: none;
.bar-btn:disabled:not(.bar-btn--active) {
opacity: 0.35;
cursor: default;
}
.bar-btn--disabled.bar-btn--active {
.bar-btn--disabled.bar-btn--active,
.bar-btn:disabled.bar-btn--active {
opacity: 1;
pointer-events: none;
cursor: default;
}
/* Active states */
@@ -47,12 +49,8 @@
}
.bar-btn--comments {
color: var(--color-muted);
}
.bar-btn--comments:not(.bar-btn--disabled) {
opacity: 1;
pointer-events: auto;
color: var(--color-text);
opacity: 0.9;
}
.bar-icon {
@@ -63,7 +61,7 @@
.bar-count {
font-family: var(--font-mono);
font-size: 11px;
font-weight: 500;
font-weight: 600;
line-height: 1;
}
+24 -20
View File
@@ -1,51 +1,54 @@
import React, { useState } from 'react';
import { useApp } from '../../context/AppContext';
import { hapticImpact } from '../../services/auth';
import './BottomBar.css';
export default function BottomBar() {
const { currentCard, chosenCard, likeCard, dislikeCard, setIsCommentsOpen, user } = useApp();
const [reactionState, setReactionState] = useState(null); // 'liked' | 'disliked' | null
const [isSubmitting, setIsSubmitting] = useState(false);
const isRevealed = chosenCard !== null;
// Check if user already reacted to this card
const alreadyLiked = user?.liked_card_ids?.includes(currentCard?.card_id);
const alreadyDisliked = user?.disliked_card_ids?.includes(currentCard?.card_id);
const currentReaction = reactionState || (alreadyLiked ? 'liked' : alreadyDisliked ? 'disliked' : null);
const currentReaction = alreadyLiked ? 'liked' : alreadyDisliked ? 'disliked' : null;
const handleLike = async () => {
if (!isRevealed || currentReaction) return;
const result = await likeCard();
if (result && !result.error) {
setReactionState('liked');
if (!isRevealed || currentReaction || isSubmitting) return;
setIsSubmitting(true);
try {
await likeCard();
} finally {
setIsSubmitting(false);
}
};
const handleDislike = async () => {
if (!isRevealed || currentReaction) return;
const result = await dislikeCard();
if (result && !result.error) {
setReactionState('disliked');
if (!isRevealed || currentReaction || isSubmitting) return;
setIsSubmitting(true);
try {
await dislikeCard();
} finally {
setIsSubmitting(false);
}
};
const handleComments = () => {
hapticImpact('light');
setIsCommentsOpen(true);
};
// Reset reaction state when card changes
React.useEffect(() => {
setReactionState(null);
}, [currentCard?.card_id]);
const likes = (currentCard?.count_likes || 0) + (reactionState === 'liked' ? 1 : 0);
const dislikes = (currentCard?.count_dislikes || 0) + (reactionState === 'disliked' ? 1 : 0);
const likes = currentCard?.count_likes || 0;
const dislikes = currentCard?.count_dislikes || 0;
const commentsCount = currentCard?.comments?.length || 0;
return (
<div className="bottom-bar">
<button
className={`bar-btn bar-btn--dislike ${currentReaction === 'disliked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`}
className={`bar-btn bar-btn--dislike ${currentReaction === 'disliked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction || isSubmitting ? 'bar-btn--disabled' : ''}`}
onClick={handleDislike}
disabled={!isRevealed || !!currentReaction || isSubmitting}
aria-label="Дизлайк"
>
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor" style={{ transform: 'rotate(180deg)' }}>
@@ -62,12 +65,13 @@ export default function BottomBar() {
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2v10z" />
</svg>
<span className="bar-count">{formatCount(currentCard?.comments?.length || 0)}</span>
<span className="bar-count">{formatCount(commentsCount)}</span>
</button>
<button
className={`bar-btn bar-btn--like ${currentReaction === 'liked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction ? 'bar-btn--disabled' : ''}`}
className={`bar-btn bar-btn--like ${currentReaction === 'liked' ? 'bar-btn--active' : ''} ${!isRevealed || currentReaction || isSubmitting ? 'bar-btn--disabled' : ''}`}
onClick={handleLike}
disabled={!isRevealed || !!currentReaction || isSubmitting}
aria-label="Лайк"
>
<svg className="bar-icon" viewBox="0 0 24 24" fill="currentColor">
+51 -10
View File
@@ -11,7 +11,7 @@
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
gap: 6px;
position: relative;
min-height: 0;
}
@@ -31,52 +31,93 @@
.card-pair--empty {
align-items: center;
justify-content: center;
text-align: center;
}
.empty-content {
display: flex;
flex-direction: column;
gap: var(--space-xl);
align-items: center;
width: 100%;
max-width: 320px;
max-width: 340px;
padding: var(--space-lg);
background: var(--color-bg-elevated);
border: 1px solid var(--glass-border);
border-radius: var(--radius-card);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
animation: hintFadeIn 600ms var(--ease-smooth);
}
.empty-badge-icon {
font-size: 36px;
margin-bottom: var(--space-sm);
}
.card-pair-empty-text {
font-family: var(--font-display);
font-size: 20px;
font-size: 19px;
font-weight: 700;
color: var(--color-text);
text-align: center;
margin-bottom: 6px;
}
.card-pair-empty-sub {
font-size: 13px;
line-height: 1.45;
color: var(--color-muted);
margin-bottom: var(--space-lg);
}
.empty-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
width: 100%;
}
.empty-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
width: 100%;
padding: var(--space-md);
padding: 12px var(--space-md);
border-radius: var(--radius-sm);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
font-size: 15px;
font-size: 14px;
font-weight: 500;
color: var(--color-text);
transition: all var(--duration-fast) var(--ease-smooth);
}
.empty-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.empty-btn:active {
background: rgba(255, 255, 255, 0.12);
transform: scale(0.98);
}
.empty-btn--refresh {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.2), rgba(2, 132, 199, 0.2));
border-color: rgba(124, 58, 237, 0.4);
color: #FFFFFF;
font-weight: 600;
}
.empty-btn--primary {
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
border-color: transparent;
font-weight: 600;
color: #FFFFFF;
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.3);
}
.empty-btn--primary:active {
transform: scale(0.97);
.refresh-icon {
transition: transform 0.4s ease;
}
.refresh-icon--spinning {
animation: spin 0.8s linear infinite;
}
+68 -12
View File
@@ -2,24 +2,74 @@ import React from 'react';
import Card from './Card';
import OrBadge from './OrBadge';
import { useApp } from '../../context/AppContext';
import { openExternalLink, hapticImpact } from '../../services/auth';
import './CardPair.css';
export default function CardPair() {
const { currentCard, chosenCard, chooseCard, openMenu, setMenuScreen } = useApp();
const { currentCard, chosenCard, chooseCard, loadCards, isLoadingCards, openMenu, setMenuScreen } = useApp();
if (!currentCard) {
return (
<div className="card-pair card-pair--empty">
<div className="empty-content">
<p className="card-pair-empty-text">Карточки закончились!</p>
<div className="empty-badge-icon"></div>
<h2 className="card-pair-empty-text">Карточки закончились!</h2>
<p className="card-pair-empty-sub">
Вы посмотрели все доступные карточки. Новые карточки появляются после прохождения модерации.
</p>
<div className="empty-actions">
<button className="empty-btn empty-btn--primary" onClick={() => { openMenu(); setMenuScreen('create'); }}>
Создать карточку
<button
className="empty-btn empty-btn--refresh"
onClick={() => loadCards(true)}
disabled={isLoadingCards}
>
<svg
className={`refresh-icon ${isLoadingCards ? 'refresh-icon--spinning' : ''}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
width="18"
height="18"
>
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 11-.57-8.38l5.67-5.67" />
</svg>
<span>{isLoadingCards ? 'Проверяем...' : 'Проверить новые карточки'}</span>
</button>
<button className="empty-btn" onClick={() => { openMenu(); setMenuScreen('about'); }}>
<button
className="empty-btn empty-btn--primary"
onClick={() => {
openMenu();
setMenuScreen('create');
}}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<path d="M12 5v14M5 12h14" />
</svg>
<span>Создать карточку</span>
</button>
<button
className="empty-btn"
onClick={() => {
openMenu();
setMenuScreen('about');
}}
>
О проекте
</button>
<button className="empty-btn" onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')}>
<button
className="empty-btn empty-btn--donate"
onClick={() => {
hapticImpact('light');
openExternalLink('https://boosty.to/pseudodev/donate');
}}
>
Поддержать проект
</button>
</div>
@@ -34,7 +84,7 @@ export default function CardPair() {
let percentB = 50;
if (chosenCard) {
// Add the current user's vote to the count for display
// Add current user's choice for visual distribution
const votesA = currentCard.count_choice_A + (chosenCard === 'A' ? 1 : 0);
const votesB = currentCard.count_choice_B + (chosenCard === 'B' ? 1 : 0);
const newTotal = votesA + votesB;
@@ -43,16 +93,22 @@ export default function CardPair() {
percentB = 100 - percentA;
}
// Clamp to 75/25 max for readability
if (percentA > 75) { percentA = 75; percentB = 25; }
if (percentB > 75) { percentB = 75; percentA = 25; }
// Clamp between 25% and 75% for readable card size balance
if (percentA > 75) {
percentA = 75;
percentB = 25;
}
if (percentB > 75) {
percentB = 75;
percentA = 25;
}
}
// flex-grow values for animation
// flex-grow values for smooth spring animation
const growA = chosenCard ? percentA : 50;
const growB = chosenCard ? percentB : 50;
// Actual percentages for display (unclamped)
// Actual display percentages
let displayPercentA = 50;
let displayPercentB = 50;
if (chosenCard && total >= 0) {
@@ -2,18 +2,26 @@
display: flex;
gap: var(--space-md);
padding: var(--space-md) 0;
transition: background var(--duration-fast) var(--ease-smooth);
}
.comment-item + .comment-item {
border-top: 1px solid rgba(255, 255, 255, 0.06);
}
.comment-item--me {
background: rgba(124, 58, 237, 0.04);
border-radius: var(--radius-sm);
padding: var(--space-sm) var(--space-xs);
}
.comment-avatar {
flex-shrink: 0;
width: 40px;
height: 40px;
width: 38px;
height: 38px;
border-radius: 50%;
overflow: hidden;
border: 1px solid var(--glass-border);
}
.comment-avatar-img {
@@ -28,13 +36,17 @@
justify-content: center;
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
background: linear-gradient(135deg, var(--color-card-a-from), var(--color-card-b-from));
font-family: var(--font-display);
font-size: 16px;
font-size: 15px;
font-weight: 700;
color: var(--color-text);
}
.comment-avatar-fallback--me {
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
}
.comment-body {
flex: 1;
min-width: 0;
@@ -42,20 +54,43 @@
.comment-header {
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
margin-bottom: 4px;
}
.comment-author-group {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.comment-author {
font-weight: 600;
font-size: 14px;
color: var(--color-stat-a);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.comment-me-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 6px;
border-radius: 4px;
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
color: #FFFFFF;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.comment-date {
font-size: 12px;
font-size: 11px;
color: var(--color-muted);
flex-shrink: 0;
}
.comment-text {
@@ -1,31 +1,57 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { currentUser } from '../../services/auth';
import './CommentItem.css';
export default function CommentItem({ comment, author }) {
const displayName = author?.username || author?.first_name || 'Аноним';
const date = comment.creation_date
const [imageError, setImageError] = useState(false);
const isMe = comment.author_id === currentUser?.id;
const displayName = isMe
? (currentUser?.username ? `@${currentUser.username}` : currentUser?.first_name || 'Вы')
: (author?.username ? `@${author.username}` : author?.first_name || `Игрок #${comment.author_id}`);
const photoUrl = isMe ? currentUser?.photo_url : author?.photo_url;
useEffect(() => {
setImageError(false);
}, [photoUrl]);
const dateStr = comment.creation_date
? new Date(comment.creation_date).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '';
const initial = displayName.replace(/^@/, '')[0]?.toUpperCase() || '?';
return (
<div className="comment-item">
<div className={`comment-item ${isMe ? 'comment-item--me' : ''}`}>
<div className="comment-avatar">
{author?.photo_url ? (
<img src={author.photo_url} alt="" className="comment-avatar-img" />
{photoUrl && !imageError ? (
<img
src={photoUrl}
alt=""
className="comment-avatar-img"
referrerPolicy="no-referrer"
onError={() => setImageError(true)}
/>
) : (
<span className="comment-avatar-fallback">
{displayName[0]?.toUpperCase() || '?'}
<span className={`comment-avatar-fallback ${isMe ? 'comment-avatar-fallback--me' : ''}`}>
{initial}
</span>
)}
</div>
<div className="comment-body">
<div className="comment-header">
<span className="comment-author">{displayName}</span>
<span className="comment-date">{date}</span>
<div className="comment-author-group">
<span className="comment-author">{displayName}</span>
{isMe && <span className="comment-me-badge">Вы</span>}
</div>
<span className="comment-date">{dateStr}</span>
</div>
<p className="comment-text">{comment.comment_text || comment.commet_text}</p>
</div>
@@ -7,6 +7,8 @@
background: var(--color-bg);
transform: translateY(100%);
transition: transform var(--duration-normal) var(--ease-smooth);
padding-top: max(0px, env(safe-area-inset-top));
padding-bottom: max(0px, env(safe-area-inset-bottom));
}
.comments-panel--open {
@@ -40,6 +42,15 @@
font-weight: 600;
}
.comments-count {
font-size: 12px;
font-weight: 700;
color: var(--color-muted);
background: rgba(255, 255, 255, 0.08);
padding: 2px 8px;
border-radius: var(--radius-full);
}
/* List */
.comments-list {
flex: 1;
@@ -47,8 +58,31 @@
padding: 0 var(--space-lg);
}
.comments-placeholder {
.comments-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--space-xl);
gap: var(--space-md);
}
.comments-spinner {
width: 28px;
height: 28px;
border: 3px solid rgba(255, 255, 255, 0.1);
border-top-color: var(--color-card-a-to);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.comments-placeholder {
text-align: center;
color: var(--color-muted);
font-size: 14px;
@@ -60,9 +94,15 @@
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
gap: var(--space-sm);
}
.comments-empty-icon {
font-size: 32px;
margin-bottom: var(--space-xs);
}
.comments-empty-text {
font-family: var(--font-display);
font-size: 16px;
@@ -89,15 +129,20 @@
.comments-input {
flex: 1;
resize: none;
padding: var(--space-sm) var(--space-md);
padding: 10px var(--space-md);
background: var(--glass-bg);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 14px;
line-height: 1.4;
min-height: 40px;
min-height: 42px;
max-height: 100px;
transition: border-color var(--duration-fast) var(--ease-smooth);
}
.comments-input:focus {
border-color: var(--color-card-a-to);
}
.comments-input::placeholder {
@@ -108,8 +153,8 @@
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
width: 42px;
height: 42px;
border-radius: 50%;
background: var(--glass-bg);
color: var(--color-muted);
@@ -118,10 +163,21 @@
}
.comments-send--active {
background: var(--color-like);
color: var(--color-text);
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
color: #FFFFFF;
box-shadow: 0 0 12px rgba(124, 58, 237, 0.3);
}
.comments-send:disabled {
cursor: default;
opacity: 0.6;
}
.comments-btn-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #FFFFFF;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@@ -1,14 +1,23 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useApp } from '../../context/AppContext';
import { showBackButton } from '../../services/auth';
import { showBackButton, currentUser, hapticNotification, hapticImpact } from '../../services/auth';
import { api } from '../../services/api';
import { currentUser } from '../../services/auth';
import CommentItem from './CommentItem';
import './CommentsPanel.css';
export default function CommentsPanel() {
const { isCommentsOpen, setIsCommentsOpen, currentCard, showToast } = useApp();
const {
isCommentsOpen,
setIsCommentsOpen,
currentCard,
showToast,
getUserProfile,
handleApiResponse,
syncCardComments,
addCommentToCard,
} = useApp();
const [comments, setComments] = useState([]);
const [authors, setAuthors] = useState({});
const [isLoading, setIsLoading] = useState(false);
const [newComment, setNewComment] = useState('');
const [isSending, setIsSending] = useState(false);
@@ -17,59 +26,100 @@ export default function CommentsPanel() {
// Telegram BackButton
useEffect(() => {
if (isCommentsOpen) {
const cleanup = showBackButton(() => setIsCommentsOpen(false));
const cleanup = showBackButton(() => {
hapticImpact('light');
setIsCommentsOpen(false);
});
return cleanup;
}
}, [isCommentsOpen, setIsCommentsOpen]);
// Load comments when panel opens
useEffect(() => {
if (isCommentsOpen && currentCard) {
loadComments();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCommentsOpen, currentCard?.card_id]);
const cardId = currentCard?.card_id;
async function loadComments() {
const loadComments = useCallback(async () => {
if (!cardId) return;
setIsLoading(true);
try {
// TODO: Replace with real GET /get_comments when backend implements it
const result = await api.getComments(currentCard.card_id);
if (!result.error) {
setComments(result.result || []);
const result = await api.getComments(cardId);
handleApiResponse(result);
if (!result.error && Array.isArray(result.result)) {
const loadedComments = result.result;
setComments(loadedComments);
syncCardComments(cardId, loadedComments.map((c) => c.comment_id));
// Fetch author profiles for all unique authors
const uniqueAuthorIds = Array.from(new Set(loadedComments.map((c) => c.author_id)));
const authorsData = {};
await Promise.all(
uniqueAuthorIds.map(async (authorId) => {
const profile = await getUserProfile(authorId);
if (profile) {
authorsData[authorId] = profile;
}
})
);
setAuthors(authorsData);
} else {
setComments([]);
}
} catch (err) {
console.error('Load comments error:', err);
} finally {
setIsLoading(false);
}
}
}, [cardId, getUserProfile, handleApiResponse, syncCardComments]);
// Load comments only when panel opens or active card ID changes
useEffect(() => {
if (isCommentsOpen && cardId) {
loadComments();
}
}, [isCommentsOpen, cardId, loadComments]);
async function handleSend() {
if (!newComment.trim() || isSending) return;
const text = newComment.trim();
if (!text || isSending || !currentCard) return;
setIsSending(true);
hapticImpact('light');
try {
const result = await api.addComment(currentUser.id, currentCard.card_id, newComment.trim());
if (!result.error) {
const result = await api.addComment(currentUser.id, currentCard.card_id, text);
handleApiResponse(result);
if (!result.error && result.result) {
setNewComment('');
showToast('Комментарий отправлен');
// Optimistically add the new comment to the list
if (result.result) {
setComments(prev => [...prev, result.result]);
// Scroll to bottom after adding
setTimeout(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, 100);
}
showToast('Комментарий опубликован');
hapticNotification('success');
const createdComment = result.result;
setComments((prev) => [...prev, createdComment]);
addCommentToCard(currentCard.card_id, createdComment.comment_id);
// Add current user to authors map
setAuthors((prev) => ({
...prev,
[currentUser.id]: currentUser,
}));
// Scroll to bottom
setTimeout(() => {
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, 100);
} else {
showToast('Ошибка: ' + (result.result || 'неизвестная'));
showToast(typeof result.result === 'string' ? result.result : 'Ошибка отправки комментария');
hapticNotification('error');
}
} catch (err) {
console.error('Send comment error:', err);
showToast('Ошибка отправки');
hapticNotification('error');
} finally {
setIsSending(false);
}
@@ -82,30 +132,44 @@ export default function CommentsPanel() {
}
}
const handleClose = () => {
hapticImpact('light');
setIsCommentsOpen(false);
};
return (
<div className={`comments-panel ${isCommentsOpen ? 'comments-panel--open' : ''}`}>
<div className="comments-header">
<button
className="comments-close"
onClick={() => setIsCommentsOpen(false)}
onClick={handleClose}
aria-label="Закрыть"
>
</button>
<h2 className="comments-title">Комментарии</h2>
<span className="comments-count">{comments.length}</span>
</div>
<div className="comments-list custom-scroll" ref={listRef}>
{isLoading ? (
<p className="comments-placeholder">Загрузка...</p>
<div className="comments-loading">
<div className="comments-spinner" />
<p className="comments-placeholder">Загрузка комментариев...</p>
</div>
) : comments.length === 0 ? (
<div className="comments-empty">
<div className="comments-empty-icon">💬</div>
<p className="comments-empty-text">Комментариев пока нет</p>
<p className="comments-empty-sub">Будь первым!</p>
<p className="comments-empty-sub">Будь первым, кто поделится мнением!</p>
</div>
) : (
comments.map((comment, i) => (
<CommentItem key={comment.comment_id || i} comment={comment} author={null} />
<CommentItem
key={comment.comment_id || i}
comment={comment}
author={authors[comment.author_id] || null}
/>
))
)}
</div>
@@ -113,22 +177,27 @@ export default function CommentsPanel() {
<div className="comments-input-area">
<textarea
className="comments-input"
placeholder="Ваш комментарий..."
placeholder="Напишите комментарий..."
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
onKeyDown={handleKeyDown}
maxLength={300}
rows={1}
disabled={isSending}
/>
<button
className={`comments-send ${newComment.trim() ? 'comments-send--active' : ''}`}
className={`comments-send ${newComment.trim() && !isSending ? 'comments-send--active' : ''}`}
onClick={handleSend}
disabled={!newComment.trim() || isSending}
aria-label="Отправить"
>
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
{isSending ? (
<span className="comments-btn-spinner" />
) : (
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
)}
</button>
</div>
</div>
+28 -13
View File
@@ -1,18 +1,38 @@
import React from 'react';
import { hapticImpact, hapticNotification, openExternalLink } from '../../services/auth';
import { useApp } from '../../context/AppContext';
import './AboutPage.css';
export default function AboutPage({ onBack }) {
const { showToast } = useApp();
function handleCopyEmail() {
navigator.clipboard.writeText('pseudo.developer.ru@gmail.com').then(() => {
// Visual feedback handled by CSS :active
}).catch(() => {
// Fallback — select text
});
hapticImpact('light');
navigator.clipboard
.writeText('pseudo.developer.ru@gmail.com')
.then(() => {
hapticNotification('success');
showToast('Email скопирован в буфер обмена');
})
.catch(() => {
showToast('pseudo.developer.ru@gmail.com');
});
}
const handleBack = () => {
hapticImpact('light');
onBack();
};
const handleOpenGithub = (e) => {
e.preventDefault();
hapticImpact('light');
openExternalLink('https://github.com/IgorVolochay/thisORthat');
};
return (
<div className="about-page custom-scroll">
<button className="about-back" onClick={onBack} aria-label="Назад">
<button className="about-back" onClick={handleBack} aria-label="Назад">
Назад
</button>
@@ -35,17 +55,12 @@ export default function AboutPage({ onBack }) {
</div>
<div className="about-links">
<a
href="https://github.com/IgorVolochay/thisORthat"
target="_blank"
rel="noopener noreferrer"
className="about-link"
>
<button className="about-link" onClick={handleOpenGithub}>
<svg viewBox="0 0 24 24" fill="currentColor" width="20" height="20">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
<span>Исходники проекта</span>
</a>
</button>
<button className="about-link" onClick={handleCopyEmail}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="20" height="20">
+21 -11
View File
@@ -23,17 +23,24 @@
margin-bottom: var(--space-md);
}
.create-rules-box {
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
border-radius: var(--radius-sm);
padding: var(--space-md);
margin-bottom: var(--space-lg);
}
.create-rules {
font-size: 13px;
line-height: 1.55;
color: var(--color-muted);
margin-bottom: var(--space-lg);
line-height: 1.5;
color: #CBD5E1;
}
.create-fields {
display: flex;
flex-direction: column;
gap: 4px;
gap: 8px;
margin-bottom: var(--space-lg);
}
@@ -41,7 +48,8 @@
position: relative;
border-radius: var(--radius-card);
padding: var(--space-lg);
min-height: 100px;
min-height: 110px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.create-field--a {
@@ -65,7 +73,7 @@
}
.create-textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.45);
}
.create-counter {
@@ -74,15 +82,15 @@
right: var(--space-md);
font-family: var(--font-mono);
font-size: 11px;
color: rgba(255, 255, 255, 0.35);
color: rgba(255, 255, 255, 0.4);
}
.create-submit {
width: 100%;
padding: var(--space-md) var(--space-lg);
padding: 14px var(--space-lg);
border-radius: var(--radius-full);
font-family: var(--font-display);
font-size: 18px;
font-size: 16px;
font-weight: 700;
color: var(--color-muted);
background: var(--glass-bg);
@@ -91,15 +99,17 @@
}
.create-submit--active {
color: var(--color-text);
color: #FFFFFF;
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
border-color: transparent;
box-shadow: 0 4px 20px rgba(124, 58, 237, 0.4);
}
.create-submit:disabled {
cursor: default;
opacity: 0.6;
}
.create-submit--active:active {
transform: scale(0.97);
transform: scale(0.98);
}
+27 -13
View File
@@ -1,13 +1,13 @@
import React, { useState } from 'react';
import { useApp } from '../../context/AppContext';
import { currentUser } from '../../services/auth';
import { currentUser, hapticImpact, hapticNotification } from '../../services/auth';
import { api } from '../../services/api';
import './CreateCard.css';
const MAX_LENGTH = 150;
export default function CreateCard({ onBack }) {
const { showToast, closeMenu } = useApp();
const { showToast, closeMenu, handleApiResponse } = useApp();
const [choiceA, setChoiceA] = useState('');
const [choiceB, setChoiceB] = useState('');
const [isSending, setIsSending] = useState(false);
@@ -17,47 +17,60 @@ export default function CreateCard({ onBack }) {
async function handleSubmit() {
if (!canSubmit) return;
setIsSending(true);
hapticImpact('light');
try {
const result = await api.addCard(choiceA.trim(), choiceB.trim(), currentUser.id);
handleApiResponse(result);
if (!result.error) {
hapticNotification('success');
showToast('Карточка отправлена на модерацию!');
setTimeout(() => {
closeMenu();
}, 2000);
}, 1800);
} else {
showToast('Ошибка: ' + (result.result || 'неизвестная'));
hapticNotification('error');
showToast(typeof result.result === 'string' ? result.result : 'Ошибка модерации или отправки');
setIsSending(false);
}
} catch (err) {
console.error('Add card error:', err);
hapticNotification('error');
showToast('Ошибка отправки');
setIsSending(false);
}
}
const handleBack = () => {
hapticImpact('light');
onBack();
};
return (
<div className="create-card custom-scroll">
<button className="create-back" onClick={onBack} aria-label="Назад">
<button className="create-back" onClick={handleBack} aria-label="Назад">
Назад
</button>
<h2 className="create-title">Создать карточку</h2>
<p className="create-rules">
При создании карточек запрещается использование мата и ссылок.
Все карточки проходят процесс модерации перед публикацией.
Лимит по длине текста: {MAX_LENGTH} символов.
</p>
<div className="create-rules-box">
<p className="create-rules">
💡 <strong>Правила публикации:</strong> Запрещены нецензурные выражения, оскорбления и спам-ссылки. Все карточки проверяются перед публикацией.
</p>
</div>
<div className="create-fields">
<div className="create-field create-field--a">
<textarea
className="create-textarea"
placeholder="Первый вариант"
placeholder="Вариант А"
value={choiceA}
onChange={(e) => setChoiceA(e.target.value.slice(0, MAX_LENGTH))}
maxLength={MAX_LENGTH}
rows={3}
disabled={isSending}
/>
<span className="create-counter">
{choiceA.length}/{MAX_LENGTH}
@@ -67,11 +80,12 @@ export default function CreateCard({ onBack }) {
<div className="create-field create-field--b">
<textarea
className="create-textarea"
placeholder="Второй вариант"
placeholder="Вариант Б"
value={choiceB}
onChange={(e) => setChoiceB(e.target.value.slice(0, MAX_LENGTH))}
maxLength={MAX_LENGTH}
rows={3}
disabled={isSending}
/>
<span className="create-counter">
{choiceB.length}/{MAX_LENGTH}
@@ -84,7 +98,7 @@ export default function CreateCard({ onBack }) {
onClick={handleSubmit}
disabled={!canSubmit}
>
{isSending ? 'Отправка...' : 'Отправить!'}
{isSending ? 'Отправка на модерацию...' : 'Отправить на модерацию'}
</button>
</div>
);
+24 -8
View File
@@ -1,6 +1,6 @@
import React, { useEffect } from 'react';
import { useApp } from '../../context/AppContext';
import { showBackButton } from '../../services/auth';
import { showBackButton, hapticImpact, openExternalLink } from '../../services/auth';
import Overlay from '../common/Overlay';
import AboutPage from './AboutPage';
import CreateCard from './CreateCard';
@@ -12,24 +12,40 @@ export default function MenuPanel() {
// Telegram BackButton for sub-screens
useEffect(() => {
if (isMenuOpen && menuScreen !== 'menu') {
const cleanup = showBackButton(() => setMenuScreen('menu'));
const cleanup = showBackButton(() => {
hapticImpact('light');
setMenuScreen('menu');
});
return cleanup;
}
if (isMenuOpen && menuScreen === 'menu') {
const cleanup = showBackButton(() => closeMenu());
const cleanup = showBackButton(() => {
hapticImpact('light');
closeMenu();
});
return cleanup;
}
}, [isMenuOpen, menuScreen, setMenuScreen, closeMenu]);
if (!isMenuOpen) return null;
const handleNavigate = (screen) => {
hapticImpact('light');
setMenuScreen(screen);
};
const handleExternalLink = (url) => {
hapticImpact('light');
openExternalLink(url);
};
// Sub-screens
if (menuScreen === 'about') {
return (
<>
<Overlay visible={true} onClick={closeMenu} />
<div className="menu-panel menu-panel--open">
<AboutPage onBack={() => setMenuScreen('menu')} />
<AboutPage onBack={() => handleNavigate('menu')} />
</div>
</>
);
@@ -40,7 +56,7 @@ export default function MenuPanel() {
<>
<Overlay visible={true} onClick={closeMenu} />
<div className="menu-panel menu-panel--open">
<CreateCard onBack={() => setMenuScreen('menu')} />
<CreateCard onBack={() => handleNavigate('menu')} />
</div>
</>
);
@@ -51,7 +67,7 @@ export default function MenuPanel() {
<Overlay visible={true} onClick={closeMenu} />
<div className="menu-panel menu-panel--open">
<nav className="menu-list">
<button className="menu-item" onClick={() => setMenuScreen('about')}>
<button className="menu-item" onClick={() => handleNavigate('about')}>
<span className="menu-icon">
<svg viewBox="0 0 24 24" fill="currentColor" width="22" height="22">
<circle cx="12" cy="12" r="10" fill="none" stroke="currentColor" strokeWidth="2" />
@@ -61,7 +77,7 @@ export default function MenuPanel() {
<span className="menu-label">О проекте</span>
</button>
<button className="menu-item" onClick={() => setMenuScreen('create')}>
<button className="menu-item" onClick={() => handleNavigate('create')}>
<span className="menu-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
@@ -73,7 +89,7 @@ export default function MenuPanel() {
<button
className="menu-item"
onClick={() => window.open('https://boosty.to/pseudodev/donate', '_blank')}
onClick={() => handleExternalLink('https://boosty.to/pseudodev/donate')}
>
<span className="menu-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" width="22" height="22">
@@ -0,0 +1,150 @@
.banned-screen {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--color-bg);
padding: var(--space-lg);
z-index: 1000;
user-select: none;
}
.banned-card {
width: 100%;
max-width: 380px;
background: var(--color-bg-elevated);
border: 1px solid rgba(239, 68, 68, 0.3);
box-shadow: 0 0 40px rgba(239, 68, 68, 0.15);
border-radius: var(--radius-card);
padding: var(--space-xl) var(--space-lg);
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
animation: bannedAppear 400ms var(--ease-spring);
}
@keyframes bannedAppear {
from {
opacity: 0;
transform: scale(0.92) translateY(12px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.banned-icon-wrapper {
width: 72px;
height: 72px;
border-radius: var(--radius-full);
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.35);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: var(--space-lg);
color: #EF4444;
box-shadow: 0 0 24px rgba(239, 68, 68, 0.25);
animation: pulseGlow 2.5s infinite ease-in-out;
}
@keyframes pulseGlow {
0%, 100% {
box-shadow: 0 0 16px rgba(239, 68, 68, 0.2);
}
50% {
box-shadow: 0 0 32px rgba(239, 68, 68, 0.45);
}
}
.banned-icon {
width: 36px;
height: 36px;
}
.banned-title {
font-family: var(--font-display);
font-size: 20px;
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-sm);
letter-spacing: -0.3px;
}
.banned-subtitle {
font-size: 14px;
color: var(--color-muted);
line-height: 1.5;
margin-bottom: var(--space-lg);
}
.banned-info-box {
display: flex;
align-items: center;
gap: var(--space-sm);
background: rgba(255, 255, 255, 0.04);
border: 1px solid var(--glass-border);
padding: 10px 14px;
border-radius: var(--radius-sm);
font-size: 12px;
color: #CBD5E1;
text-align: left;
margin-bottom: var(--space-xl);
width: 100%;
}
.banned-info-dot {
width: 8px;
height: 8px;
border-radius: var(--radius-full);
background: #EF4444;
flex-shrink: 0;
}
.banned-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
width: 100%;
}
.banned-btn {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
width: 100%;
padding: 12px var(--space-md);
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all var(--duration-fast) var(--ease-smooth);
}
.banned-btn--primary {
background: linear-gradient(135deg, var(--color-card-a-to), var(--color-card-b-to));
color: #FFFFFF;
}
.banned-btn--primary:hover {
filter: brightness(1.1);
transform: translateY(-1px);
}
.banned-btn--primary:active {
transform: translateY(0);
}
.banned-btn--secondary {
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--glass-border);
color: var(--color-text);
}
.banned-btn--secondary:hover {
background: rgba(255, 255, 255, 0.1);
}
@@ -0,0 +1,51 @@
import React from 'react';
import './BannedScreen.css';
export default function BannedScreen({ onRetry }) {
const handleSupportClick = () => {
window.open('https://t.me/IgorVolochay', '_blank');
};
return (
<div className="banned-screen">
<div className="banned-card">
<div className="banned-icon-wrapper">
<svg className="banned-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<h1 className="banned-title">Доступ ограничен</h1>
<p className="banned-subtitle">
Система безопасности зафиксировала подозрительную активность с вашего IP-адреса.
</p>
<div className="banned-info-box">
<div className="banned-info-dot" />
<span>Блокировка длится 1 час и снимается автоматически.</span>
</div>
<div className="banned-actions">
{onRetry && (
<button className="banned-btn banned-btn--primary" onClick={onRetry}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 11-.57-8.38l5.67-5.67" />
</svg>
<span>Повторить попытку</span>
</button>
)}
<button className="banned-btn banned-btn--secondary" onClick={handleSupportClick}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<path d="M21 11.5a8.38 8.38 0 01-.9 3.8 8.5 8.5 0 01-7.6 4.7 8.38 8.38 0 01-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 01-.9-3.8 8.5 8.5 0 014.7-7.6 8.38 8.38 0 013.8-.9h.5a8.48 8.48 0 018 8v.5z" />
</svg>
<span>Написать в поддержку</span>
</button>
</div>
</div>
</div>
);
}
+247 -67
View File
@@ -1,6 +1,6 @@
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react';
import { api } from '../services/api';
import { currentUser, initTelegramApp } from '../services/auth';
import { currentUser, initTelegramApp, hapticImpact, hapticNotification } from '../services/auth';
const AppContext = createContext(null);
@@ -9,11 +9,13 @@ export function AppProvider({ children }) {
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState(null);
const [error, setError] = useState(null);
const [isBanned, setIsBanned] = useState(false);
// Card queue
const [cardQueue, setCardQueue] = useState([]);
const [currentCardIndex, setCurrentCardIndex] = useState(0);
const [chosenCard, setChosenCard] = useState(null); // null | "A" | "B"
const [isLoadingCards, setIsLoadingCards] = useState(false);
// Panels
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -22,84 +24,152 @@ export function AppProvider({ children }) {
// Toast
const [toast, setToast] = useState(null);
const toastTimeoutRef = useRef(null);
// User Profile Cache for comments
const userProfileCacheRef = useRef(new Map());
// Initialization ref for React StrictMode
const isInitializingRef = useRef(false);
// Current card helper
const currentCard = cardQueue[currentCardIndex] || null;
// Initialize app
useEffect(() => {
async function init() {
try {
initTelegramApp();
// Check/register user
const checkResult = await api.checkUser(currentUser.id);
if (!checkResult.result) {
await api.addUser({
user_id: currentUser.id,
username: currentUser.username,
first_name: currentUser.first_name,
last_name: currentUser.last_name,
photo_url: currentUser.photo_url,
});
}
const userResult = await api.getUser(currentUser.id);
if (!userResult.error) {
setUser(userResult.result);
}
// Load first batch of cards
await loadCards();
} catch (err) {
setError('Не удалось загрузить приложение');
console.error('Init error:', err);
} finally {
setIsLoading(false);
}
// Toast helper
const showToast = useCallback((message, duration = 2500) => {
if (toastTimeoutRef.current) {
clearTimeout(toastTimeoutRef.current);
}
init();
// eslint-disable-next-line react-hooks/exhaustive-deps
setToast(message);
toastTimeoutRef.current = setTimeout(() => setToast(null), duration);
}, []);
// Response interceptor helper
const handleApiResponse = useCallback((res) => {
if (res?.isBanned) {
setIsBanned(true);
}
if (res?.status === 429) {
showToast(typeof res.result === 'string' ? res.result : 'Слишком много запросов. Подождите немного.');
}
return res;
}, [showToast]);
// Load cards batch
const loadCards = useCallback(async () => {
const loadCards = useCallback(async (isManualRefresh = false) => {
setIsLoadingCards(true);
if (isManualRefresh) {
hapticImpact('light');
}
try {
const result = await api.getRandomCards(currentUser.id);
handleApiResponse(result);
if (!result.error && Array.isArray(result.result) && result.result.length > 0) {
setCardQueue(result.result);
setCurrentCardIndex(0);
setChosenCard(null);
if (isManualRefresh) {
showToast('Карточки обновлены!');
hapticNotification('success');
}
} else {
// No more cards or error
// Pool is empty or all cards seen
setCardQueue([]);
setCurrentCardIndex(0);
setChosenCard(null);
if (isManualRefresh) {
showToast('Новых карточек пока нет');
}
}
} catch (err) {
console.error('Load cards error:', err);
setError('Ошибка загрузки карточек');
}
}, []);
// Choose a card (A or B)
const chooseCard = useCallback((choice) => {
if (chosenCard) {
// Second tap on chosen card go next
if (choice === chosenCard) {
goToNextCard();
if (isManualRefresh) {
showToast('Ошибка загрузки карточек');
}
return;
} finally {
setIsLoadingCards(false);
}
setChosenCard(choice);
// Fire select_choice to backend
if (currentCard) {
api.selectChoice(currentUser.id, currentCard.card_id, choice).catch(console.error);
}, [handleApiResponse, showToast]);
// Initialize app
const initApp = useCallback(async () => {
try {
initTelegramApp();
// Check/register user
const checkResult = await api.checkUser(currentUser.id);
handleApiResponse(checkResult);
if (checkResult?.isBanned) return;
if (!checkResult.error && !checkResult.result) {
const addResult = await api.addUser({
user_id: currentUser.id,
username: currentUser.username,
first_name: currentUser.first_name,
last_name: currentUser.last_name,
photo_url: currentUser.photo_url,
});
handleApiResponse(addResult);
if (addResult?.isBanned) return;
}
const userResult = await api.getUser(currentUser.id);
handleApiResponse(userResult);
if (userResult?.isBanned) return;
if (!userResult.error && userResult.result) {
setUser(userResult.result);
}
// Load first batch of cards
await loadCards();
} catch (err) {
setError('Не удалось загрузить приложение');
console.error('Init error:', err);
} finally {
setIsLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [chosenCard, currentCard]);
}, [handleApiResponse, loadCards]);
useEffect(() => {
if (isInitializingRef.current) return;
isInitializingRef.current = true;
initApp();
}, [initApp]);
// User Profile resolver for comments
const getUserProfile = useCallback(async (userId) => {
if (!userId) return null;
if (userId === currentUser.id) {
return {
user_id: currentUser.id,
username: currentUser.username,
first_name: currentUser.first_name,
last_name: currentUser.last_name,
photo_url: currentUser.photo_url,
};
}
if (userProfileCacheRef.current.has(userId)) {
return userProfileCacheRef.current.get(userId);
}
try {
const res = await api.getUser(userId);
handleApiResponse(res);
if (!res.error && res.result) {
userProfileCacheRef.current.set(userId, res.result);
return res.result;
}
} catch (err) {
console.error('Failed to get user profile:', userId, err);
}
return null;
}, [handleApiResponse]);
// Go to next card
const goToNextCard = useCallback(async () => {
hapticImpact('light');
const nextIndex = currentCardIndex + 1;
if (nextIndex < cardQueue.length) {
setCurrentCardIndex(nextIndex);
@@ -110,50 +180,155 @@ export function AppProvider({ children }) {
}
}, [currentCardIndex, cardQueue.length, loadCards]);
// Choose a card (A or B)
const chooseCard = useCallback((choice) => {
if (chosenCard) {
// Second tap on chosen card go next
if (choice === chosenCard) {
goToNextCard();
}
return;
}
hapticImpact('medium');
setChosenCard(choice);
if (currentCard) {
api.selectChoice(currentUser.id, currentCard.card_id, choice)
.then(handleApiResponse)
.catch(console.error);
}
}, [chosenCard, currentCard, goToNextCard, handleApiResponse]);
const syncCardComments = useCallback((cardId, commentsArrayOrIds) => {
if (!Array.isArray(commentsArrayOrIds)) return;
setCardQueue((prev) => {
const targetCard = prev.find((c) => c.card_id === cardId);
if (!targetCard) return prev;
if (targetCard.comments && targetCard.comments.length === commentsArrayOrIds.length) {
return prev; // No change, avoid re-render
}
return prev.map((c) =>
c.card_id === cardId
? { ...c, comments: commentsArrayOrIds }
: c
);
});
}, []);
const addCommentToCard = useCallback((cardId, commentId) => {
setCardQueue((prev) =>
prev.map((c) =>
c.card_id === cardId
? {
...c,
comments: c.comments ? [...c.comments, commentId] : [commentId],
}
: c
)
);
}, []);
// Auto-sync real comments count for current card from /get_comments
useEffect(() => {
const cardId = currentCard?.card_id;
if (!cardId) return;
let isMounted = true;
api.getComments(cardId)
.then((res) => {
if (isMounted && !res.error && Array.isArray(res.result)) {
syncCardComments(cardId, res.result.map((c) => c.comment_id));
}
})
.catch(() => { });
return () => {
isMounted = false;
};
}, [currentCard?.card_id, syncCardComments]);
// Reactions
const likeCard = useCallback(async () => {
if (!currentCard || !chosenCard) return;
hapticImpact('light');
const result = await api.likeCard(currentUser.id, currentCard.card_id);
if (!result.error) {
// Refresh user data to get updated liked_card_ids
handleApiResponse(result);
if (result && !result.error) {
// Update local card counts in cardQueue
setCardQueue((prev) =>
prev.map((c) =>
c.card_id === currentCard.card_id
? { ...c, count_likes: (c.count_likes || 0) + 1 }
: c
)
);
// Refresh user data for liked_card_ids
const userResult = await api.getUser(currentUser.id);
if (!userResult.error) setUser(userResult.result);
handleApiResponse(userResult);
if (!userResult.error && userResult.result) {
setUser(userResult.result);
}
}
return result;
}, [currentCard, chosenCard]);
}, [currentCard, chosenCard, handleApiResponse]);
const dislikeCard = useCallback(async () => {
if (!currentCard || !chosenCard) return;
hapticImpact('light');
const result = await api.dislikeCard(currentUser.id, currentCard.card_id);
if (!result.error) {
handleApiResponse(result);
if (result && !result.error) {
// Update local card counts in cardQueue
setCardQueue((prev) =>
prev.map((c) =>
c.card_id === currentCard.card_id
? { ...c, count_dislikes: (c.count_dislikes || 0) + 1 }
: c
)
);
// Refresh user data for disliked_card_ids
const userResult = await api.getUser(currentUser.id);
if (!userResult.error) setUser(userResult.result);
handleApiResponse(userResult);
if (!userResult.error && userResult.result) {
setUser(userResult.result);
}
}
return result;
}, [currentCard, chosenCard]);
// Toast helper
const showToast = useCallback((message, duration = 2500) => {
setToast(message);
setTimeout(() => setToast(null), duration);
}, []);
}, [currentCard, chosenCard, handleApiResponse]);
// Menu helpers
const openMenu = useCallback(() => {
hapticImpact('light');
setIsMenuOpen(true);
setMenuScreen('menu');
}, []);
const closeMenu = useCallback(() => {
hapticImpact('light');
setIsMenuOpen(false);
setMenuScreen('menu');
}, []);
const handleRetryAfterBan = useCallback(() => {
setIsBanned(false);
setIsLoading(true);
initApp();
}, [initApp]);
const value = {
// State
isLoading,
isLoadingCards,
user,
error,
isBanned,
currentCard,
chosenCard,
cardQueue,
@@ -169,12 +344,17 @@ export function AppProvider({ children }) {
loadCards,
likeCard,
dislikeCard,
getUserProfile,
handleApiResponse,
syncCardComments,
addCommentToCard,
openMenu,
closeMenu,
setMenuScreen,
setIsCommentsOpen,
showToast,
setError,
handleRetryAfterBan,
};
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
+66 -11
View File
@@ -1,22 +1,80 @@
/**
* API service all backend requests for This OR That.
* All endpoints return { result, error } (BaseResponse).
* Includes X-Init-Data header injection, rate limit handling, and IP ban detection.
* All endpoints return { result, error, status, isBanned }.
*/
const BASE_URL = process.env.REACT_APP_API_URL || '/api';
import { getTelegramInitData } from './auth';
const BASE_URL = process.env.REACT_APP_API_URL || '';
async function request(method, path, body = null) {
const headers = {
'Content-Type': 'application/json',
};
const initData = getTelegramInitData();
if (initData) {
headers['X-Init-Data'] = initData;
}
const options = {
method,
headers: { 'Content-Type': 'application/json' },
headers,
};
if (body) {
options.body = JSON.stringify(body);
}
const response = await fetch(`${BASE_URL}${path}`, options);
const data = await response.json();
return data;
try {
const response = await fetch(`${BASE_URL}${path}`, options);
let data;
try {
data = await response.json();
} catch {
data = { result: response.statusText, error: !response.ok };
}
// Check for IP ban by FastAPI-guard / penetration detection
const isIpBanned = response.status === 403 && (
(typeof data?.detail === 'string' && /banned|ip.*banned|suspicious/i.test(data.detail)) ||
(typeof data?.result === 'string' && /banned|ip.*banned|suspicious/i.test(data.result))
);
if (!response.ok) {
if (response.status === 429) {
return {
result: data?.detail || 'Слишком много запросов. Подождите несколько секунд.',
error: true,
status: 429,
isBanned: false,
};
}
return {
result: data?.result || data?.detail || `Ошибка сервера (${response.status})`,
error: true,
status: response.status,
isBanned: isIpBanned,
};
}
return {
result: data?.result !== undefined ? data.result : data,
error: data?.error || false,
status: response.status,
isBanned: false,
};
} catch (err) {
console.error(`API request error [${method} ${path}]:`, err);
return {
result: 'Ошибка соединения с сервером',
error: true,
status: 0,
isBanned: false,
};
}
}
const GET = (path) => request('GET', path);
@@ -58,9 +116,6 @@ export const api = {
addComment: (authorId, cardId, commentText) =>
POST('/comment', { author_id: authorId, card_id: cardId, comment_text: commentText }),
// TODO: GET /get_comments — endpoint not yet implemented on backend
getComments: (cardId) => {
console.warn('GET /get_comments not implemented on backend yet');
return Promise.resolve({ result: [], error: false });
},
getComments: (cardId) =>
GET(`/get_comments?card_id=${cardId}`),
};
+63 -3
View File
@@ -1,6 +1,6 @@
/**
* Auth service detects Telegram WebApp user or falls back to mock.
* Auto-registers user on backend if not yet registered.
* Provides Telegram Mini App initialization, initData extraction, and Haptic Feedback.
*/
const MOCK_USER = {
@@ -11,7 +11,21 @@ const MOCK_USER = {
photo_url: '',
};
function getTelegramUser() {
/**
* Extracts raw Telegram initData query-string for backend HMAC validation.
*/
export function getTelegramInitData() {
try {
return window.Telegram?.WebApp?.initData || '';
} catch {
return '';
}
}
/**
* Extracts parsed user info from initDataUnsafe for UI display.
*/
export function getTelegramUser() {
try {
const tg = window.Telegram?.WebApp;
const user = tg?.initDataUnsafe?.user;
@@ -33,14 +47,47 @@ function getTelegramUser() {
export const currentUser = getTelegramUser() ?? MOCK_USER;
export const isTelegram = !!getTelegramUser();
/**
* Initializes Telegram WebApp environment (theme, fullscreen expand, close confirmation).
*/
export function initTelegramApp() {
const tg = window.Telegram?.WebApp;
if (tg) {
tg.ready();
tg.expand();
try {
tg.setHeaderColor?.('#070711');
tg.setBackgroundColor?.('#070711');
tg.enableClosingConfirmation?.();
} catch {
// Ignored in unsupported client versions
}
}
}
/**
* Triggers Telegram Haptic Feedback impact.
* @param {'light' | 'medium' | 'heavy' | 'rigid' | 'soft'} style
*/
export function hapticImpact(style = 'light') {
try {
window.Telegram?.WebApp?.HapticFeedback?.impactOccurred(style);
} catch {}
}
/**
* Triggers Telegram Haptic Feedback notification.
* @param {'error' | 'success' | 'warning'} type
*/
export function hapticNotification(type = 'success') {
try {
window.Telegram?.WebApp?.HapticFeedback?.notificationOccurred(type);
} catch {}
}
/**
* Handles Telegram BackButton lifecycle with cleanup.
*/
export function showBackButton(onBack) {
const tg = window.Telegram?.WebApp;
if (tg?.BackButton) {
@@ -51,5 +98,18 @@ export function showBackButton(onBack) {
tg.BackButton.hide();
};
}
return () => { };
return () => {};
}
/**
* Safely opens external link in Telegram WebApp or browser.
*/
export function openExternalLink(url) {
try {
if (window.Telegram?.WebApp?.openLink) {
window.Telegram.WebApp.openLink(url);
return;
}
} catch {}
window.open(url, '_blank', 'noopener,noreferrer');
}
+1 -5
View File
@@ -1,5 +1 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';
// setupTests.js