{t('admin.broadcasts.atLeastOneChannel')}
+ )} + {bothChannels && ( +{t('admin.broadcasts.sendingBoth')}
+ )}+ {msg.message_text} +
+ {msg.has_media && msg.media_file_id && ( +{msg.media_caption}
+ )} +{t('admin.users.detail.noTickets')}
+{message}
-, ) but uses
+ // newlines for structure. Convert newlines to paragraphs while preserving inline tags.
const result = content
- .split(/\n\n+/) // Split by double newlines (paragraphs)
+ .split(/\n\n+/)
.map((paragraph) => {
- // Check if it's a header (starts with # or numeric like "1.")
- if (/^#{1,4}\s/.test(paragraph)) {
- const level = paragraph.match(/^(#{1,4})/)?.[1].length || 1;
- const text = paragraph.replace(/^#{1,4}\s*/, '');
+ const trimmed = paragraph.trim();
+ if (!trimmed) return '';
+
+ // Check if it's a markdown header
+ if (/^#{1,4}\s/.test(trimmed)) {
+ const level = trimmed.match(/^(#{1,4})/)?.[1].length || 1;
+ const text = trimmed.replace(/^#{1,4}\s*/, '');
return `${text} `;
}
// Check for list items
- if (/^[-•]\s/.test(paragraph) || /^\d+[.)]\s/.test(paragraph)) {
- const lines = paragraph.split('\n');
+ if (/^[-•]\s/.test(trimmed) || /^\d+[.)]\s/.test(trimmed)) {
+ const lines = trimmed.split('\n');
const isOrdered = /^\d+[.)]\s/.test(lines[0]);
const listItems = lines
.map((line) => line.replace(/^[-•]\s*/, '').replace(/^\d+[.)]\s*/, ''))
@@ -135,11 +143,11 @@ const formatContent = (content: string): string => {
return isOrdered ? `${listItems}
` : `${listItems}
`;
}
- // Regular paragraph - handle single line breaks within
- const formattedParagraph = paragraph.split('\n').join('
');
-
- return `${formattedParagraph}
`;
+ // Regular paragraph — single newlines become
+ const formatted = trimmed.split('\n').join('
');
+ return `${formatted}
`;
})
+ .filter(Boolean)
.join('');
return sanitizeHtml(result);
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx
index 76b9f3b..6f1a58f 100644
--- a/src/pages/Login.tsx
+++ b/src/pages/Login.tsx
@@ -18,6 +18,8 @@ import { getAndClearReturnUrl } from '../utils/token';
import { isInTelegramWebApp, getTelegramInitData } from '../hooks/useTelegramSDK';
import LanguageSwitcher from '../components/LanguageSwitcher';
import TelegramLoginButton from '../components/TelegramLoginButton';
+import OAuthProviderIcon from '../components/OAuthProviderIcon';
+import { saveOAuthState } from './OAuthCallback';
export default function Login() {
const { t } = useTranslation();
@@ -92,6 +94,29 @@ export default function Login() {
});
const isEmailAuthEnabled = emailAuthConfig?.enabled ?? true;
+ // Fetch enabled OAuth providers
+ const { data: oauthData } = useQuery({
+ queryKey: ['oauth-providers'],
+ queryFn: authApi.getOAuthProviders,
+ staleTime: 60000,
+ });
+ const oauthProviders = oauthData?.providers ?? [];
+
+ const [oauthLoading, setOauthLoading] = useState(null);
+
+ const handleOAuthLogin = async (provider: string) => {
+ setError('');
+ setOauthLoading(provider);
+ try {
+ const { authorize_url, state } = await authApi.getOAuthAuthorizeUrl(provider);
+ saveOAuthState(state, provider);
+ window.location.href = authorize_url;
+ } catch {
+ setError(t('auth.oauthError', 'Authorization was denied or failed'));
+ setOauthLoading(null);
+ }
+ };
+
const botUsername = import.meta.env.VITE_TELEGRAM_BOT_USERNAME || '';
// If email auth is disabled but user came with ref param, redirect to bot
@@ -424,6 +449,38 @@ export default function Login() {
)}
+ {/* OAuth providers section */}
+ {oauthProviders.length > 0 && (
+ <>
+
+
+ {t('auth.or', 'or')}
+
+
+
+ {oauthProviders.map((provider) => (
+ handleOAuthLogin(provider.name)}
+ disabled={oauthLoading !== null}
+ className="hover:bg-dark-750 flex w-full items-center justify-center gap-3 rounded-xl border border-dark-700 bg-dark-800 px-4 py-3 text-sm font-medium text-dark-100 transition-colors hover:border-dark-600 disabled:opacity-50"
+ >
+ {oauthLoading === provider.name ? (
+
+ ) : (
+
+ )}
+ {t(
+ `auth.continueWith${provider.name.charAt(0).toUpperCase() + provider.name.slice(1)}`,
+ `Continue with ${provider.display_name}`,
+ )}
+
+ ))}
+
+ >
+ )}
+
{/* Email auth section - only when enabled */}
{isEmailAuthEnabled && (
<>
diff --git a/src/pages/OAuthCallback.tsx b/src/pages/OAuthCallback.tsx
new file mode 100644
index 0000000..7078fc5
--- /dev/null
+++ b/src/pages/OAuthCallback.tsx
@@ -0,0 +1,119 @@
+import { useEffect, useState } from 'react';
+import { useNavigate, useSearchParams } from 'react-router';
+import { useTranslation } from 'react-i18next';
+import { useAuthStore } from '../store/auth';
+
+// SessionStorage helpers for OAuth state
+const OAUTH_STATE_KEY = 'oauth_state';
+const OAUTH_PROVIDER_KEY = 'oauth_provider';
+
+export function saveOAuthState(state: string, provider: string): void {
+ sessionStorage.setItem(OAUTH_STATE_KEY, state);
+ sessionStorage.setItem(OAUTH_PROVIDER_KEY, provider);
+}
+
+export function getAndClearOAuthState(): { state: string; provider: string } | null {
+ const state = sessionStorage.getItem(OAUTH_STATE_KEY);
+ const provider = sessionStorage.getItem(OAUTH_PROVIDER_KEY);
+ sessionStorage.removeItem(OAUTH_STATE_KEY);
+ sessionStorage.removeItem(OAUTH_PROVIDER_KEY);
+ if (!state || !provider) return null;
+ return { state, provider };
+}
+
+export default function OAuthCallback() {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const [searchParams] = useSearchParams();
+ const [error, setError] = useState('');
+ const { loginWithOAuth, isAuthenticated } = useAuthStore();
+
+ useEffect(() => {
+ if (isAuthenticated) {
+ navigate('/', { replace: true });
+ return;
+ }
+
+ const authenticate = async () => {
+ const code = searchParams.get('code');
+ const urlState = searchParams.get('state');
+
+ if (!code || !urlState) {
+ setError(t('auth.oauthError', 'Authorization was denied or failed'));
+ return;
+ }
+
+ // Get saved state from sessionStorage
+ const saved = getAndClearOAuthState();
+ if (!saved) {
+ setError(t('auth.oauthExpired', 'OAuth session expired. Please try again.'));
+ return;
+ }
+
+ // Validate state match
+ if (saved.state !== urlState) {
+ setError(t('auth.oauthError', 'Authorization was denied or failed'));
+ return;
+ }
+
+ try {
+ await loginWithOAuth(saved.provider, code, urlState);
+ navigate('/', { replace: true });
+ } catch (err: unknown) {
+ const error = err as { response?: { data?: { detail?: string } } };
+ setError(
+ error.response?.data?.detail ||
+ t('auth.oauthError', 'Authorization was denied or failed'),
+ );
+ }
+ };
+
+ authenticate();
+ }, [searchParams, loginWithOAuth, navigate, isAuthenticated, t]);
+
+ if (error) {
+ return (
+
+
+
+
+
+
+
+ {t('auth.loginFailed')}
+ {error}
+ navigate('/login', { replace: true })}
+ className="btn-primary w-full"
+ >
+ {t('auth.backToLogin', 'Back to login')}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {t('auth.authenticating')}
+ {t('common.loading')}
+
+
+ );
+}
diff --git a/src/pages/TopUpMethodSelect.tsx b/src/pages/TopUpMethodSelect.tsx
index 1801e6a..b5fa841 100644
--- a/src/pages/TopUpMethodSelect.tsx
+++ b/src/pages/TopUpMethodSelect.tsx
@@ -7,6 +7,7 @@ import { balanceApi } from '../api/balance';
import { useCurrency } from '../hooks/useCurrency';
import { Card } from '@/components/data-display/Card';
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
+import PaymentMethodIcon from '@/components/PaymentMethodIcon';
export default function TopUpMethodSelect() {
const { t } = useTranslation();
@@ -70,8 +71,11 @@ export default function TopUpMethodSelect() {
className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''}
onClick={() => method.is_available && handleMethodClick(method.id)}
>
-
- {translatedName || method.name}
+
+
+
+ {translatedName || method.name}
+
{(translatedDesc || method.description) && (
diff --git a/src/pages/Wheel.tsx b/src/pages/Wheel.tsx
index 6a2cca0..1bb03f1 100644
--- a/src/pages/Wheel.tsx
+++ b/src/pages/Wheel.tsx
@@ -4,7 +4,6 @@ import { useTranslation } from 'react-i18next';
import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel';
import FortuneWheel from '../components/wheel/FortuneWheel';
import WheelLegend from '../components/wheel/WheelLegend';
-import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
import { usePlatform, useHaptic } from '@/platform';
import { useNotify } from '@/platform/hooks/useNotify';
import { Card } from '@/components/data-display/Card/Card';
@@ -461,11 +460,13 @@ export default function Wheel() {
const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days;
const bothMethodsAvailable = !!(starsEnabled && daysEnabled);
+ // Stars via Telegram invoice don't require ruble balance, so only check daily limit
+ const dailyLimitReached = config.daily_limit > 0 && config.user_spins_today >= config.daily_limit;
const spinDisabled =
- !config.can_spin ||
isSpinning ||
isPayingStars ||
- (config.daily_limit > 0 && config.user_spins_today >= config.daily_limit);
+ dailyLimitReached ||
+ (paymentType === 'telegram_stars' ? !starsEnabled : !config.can_spin);
return (
@@ -570,25 +571,21 @@ export default function Wheel() {
)}
- {/* Cannot spin hint */}
- {!config.can_spin && !isSpinning && (
- <>
- {config.can_spin_reason === 'daily_limit_reached' ? (
-
- {t('wheel.errors.dailyLimitReached')}
-
- ) : config.can_spin_reason === 'insufficient_balance' ? (
-
- ) : (
-
- {t('wheel.errors.cannotSpin')}
-
- )}
- >
+ {/* Cannot spin hint — only show for days payment (Stars via invoice always works) */}
+ {!isSpinning && paymentType !== 'telegram_stars' && !config.can_spin && (
+
+
+ {config.can_spin_reason === 'daily_limit_reached'
+ ? t('wheel.errors.dailyLimitReached')
+ : t('wheel.errors.cannotSpin')}
+
+
+ )}
+ {/* Daily limit hint for Stars payment (not covered by can_spin check) */}
+ {!isSpinning && paymentType === 'telegram_stars' && dailyLimitReached && (
+
+ {t('wheel.errors.dailyLimitReached')}
+
)}
{/* Inline Result Card */}
diff --git a/src/store/auth.ts b/src/store/auth.ts
index 91b2fd0..56364bb 100644
--- a/src/store/auth.ts
+++ b/src/store/auth.ts
@@ -33,6 +33,7 @@ interface AuthState {
loginWithTelegram: (initData: string) => Promise;
loginWithTelegramWidget: (data: TelegramWidgetData) => Promise;
loginWithEmail: (email: string, password: string) => Promise;
+ loginWithOAuth: (provider: string, code: string, state: string) => Promise;
registerWithEmail: (
email: string,
password: string,
@@ -267,6 +268,18 @@ export const useAuthStore = create()(
await get().checkAdminStatus();
},
+ loginWithOAuth: async (provider, code, state) => {
+ const response = await authApi.oauthCallback(provider, code, state);
+ tokenStorage.setTokens(response.access_token, response.refresh_token);
+ set({
+ accessToken: response.access_token,
+ refreshToken: response.refresh_token,
+ user: response.user,
+ isAuthenticated: true,
+ });
+ await get().checkAdminStatus();
+ },
+
registerWithEmail: async (email, password, firstName, referralCode) => {
// Registration now returns message, not tokens
// User must verify email before they can login
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 16c9bbf..3778807 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -270,6 +270,14 @@
}
}
+/* Twemoji — cross-platform emoji rendering */
+img.twemoji {
+ display: inline-block;
+ width: 1.2em;
+ height: 1.2em;
+ vertical-align: -0.2em;
+}
+
@layer components {
/* ========== BENTO DESIGN SYSTEM ========== */
@@ -845,6 +853,19 @@
@apply italic text-dark-300;
}
+ .prose u {
+ @apply underline underline-offset-2;
+ }
+
+ .prose s,
+ .prose del {
+ @apply text-dark-400 line-through;
+ }
+
+ .prose tg-spoiler {
+ @apply cursor-pointer rounded bg-dark-600 px-1 text-transparent transition-colors hover:bg-transparent hover:text-dark-200;
+ }
+
.prose blockquote {
@apply my-4 rounded-r-lg border-l-4 border-accent-500/50 bg-dark-800/30 py-2 pl-4 italic text-dark-300;
}
@@ -921,6 +942,15 @@
@apply text-champagne-700;
}
+ .light .prose s,
+ .light .prose del {
+ @apply text-champagne-500;
+ }
+
+ .light .prose tg-spoiler {
+ @apply bg-champagne-300 text-transparent hover:bg-transparent hover:text-champagne-800;
+ }
+
.light .prose blockquote {
@apply border-champagne-400 bg-champagne-100/50 text-champagne-700;
}
diff --git a/src/types/index.ts b/src/types/index.ts
index 4d45348..23bc36e 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -12,7 +12,13 @@ export interface User {
referral_code: string | null;
language: string;
created_at: string;
- auth_type: 'telegram' | 'email'; // Тип аутентификации
+ auth_type: 'telegram' | 'email' | 'google' | 'yandex' | 'discord' | 'vk'; // Тип аутентификации
+}
+
+// OAuth types
+export interface OAuthProvider {
+ name: string;
+ display_name: string;
}
// Auth types