mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 02:23:47 +00:00
Add Telegram WebApp support: initialize app, enhance layout with fullscreen toggle, and improve polling mechanism in Wheel component
This commit is contained in:
@@ -14,6 +14,7 @@ import { wheelApi } from '../../api/wheel'
|
|||||||
import { themeColorsApi } from '../../api/themeColors'
|
import { themeColorsApi } from '../../api/themeColors'
|
||||||
import { promoApi } from '../../api/promo'
|
import { promoApi } from '../../api/promo'
|
||||||
import { useTheme } from '../../hooks/useTheme'
|
import { useTheme } from '../../hooks/useTheme'
|
||||||
|
import { useTelegramWebApp } from '../../hooks/useTelegramWebApp'
|
||||||
|
|
||||||
// Fallback branding from environment variables
|
// Fallback branding from environment variables
|
||||||
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
|
const FALLBACK_NAME = import.meta.env.VITE_APP_NAME || 'Cabinet'
|
||||||
@@ -122,6 +123,18 @@ const WheelIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const FullscreenIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
|
const ExitFullscreenIcon = () => (
|
||||||
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
|
||||||
export default function Layout({ children }: LayoutProps) {
|
export default function Layout({ children }: LayoutProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
@@ -129,6 +142,7 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
const { toggleTheme, isDark } = useTheme()
|
const { toggleTheme, isDark } = useTheme()
|
||||||
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
|
const [userPhotoUrl, setUserPhotoUrl] = useState<string | null>(null)
|
||||||
|
const { isTelegramWebApp, isFullscreen, isFullscreenSupported, toggleFullscreen } = useTelegramWebApp()
|
||||||
|
|
||||||
// Fetch enabled themes from API - same source of truth as AdminSettings
|
// Fetch enabled themes from API - same source of truth as AdminSettings
|
||||||
const { data: enabledThemes } = useQuery({
|
const { data: enabledThemes } = useQuery({
|
||||||
@@ -352,6 +366,20 @@ export default function Layout({ children }: LayoutProps) {
|
|||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="flex items-center gap-2 sm:gap-3">
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
|
{/* Fullscreen toggle - only show in Telegram WebApp */}
|
||||||
|
{isTelegramWebApp && isFullscreenSupported && (
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
className="relative p-2.5 rounded-xl transition-all duration-300 hover:scale-110 active:scale-95
|
||||||
|
dark:text-dark-400 dark:hover:text-dark-100 dark:hover:bg-dark-800
|
||||||
|
text-champagne-500 hover:text-champagne-800 hover:bg-champagne-200/50"
|
||||||
|
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||||
|
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Theme toggle button - only show if both themes are enabled */}
|
{/* Theme toggle button - only show if both themes are enabled */}
|
||||||
{canToggle && (
|
{canToggle && (
|
||||||
<button
|
<button
|
||||||
|
|||||||
113
src/hooks/useTelegramWebApp.ts
Normal file
113
src/hooks/useTelegramWebApp.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for Telegram WebApp API integration
|
||||||
|
* Provides fullscreen mode, safe area insets, and other WebApp features
|
||||||
|
*/
|
||||||
|
export function useTelegramWebApp() {
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
const [isTelegramWebApp, setIsTelegramWebApp] = useState(false)
|
||||||
|
const [safeAreaInset, setSafeAreaInset] = useState({ top: 0, bottom: 0, left: 0, right: 0 })
|
||||||
|
|
||||||
|
const webApp = typeof window !== 'undefined' ? window.Telegram?.WebApp : undefined
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!webApp) {
|
||||||
|
setIsTelegramWebApp(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsTelegramWebApp(true)
|
||||||
|
setIsFullscreen(webApp.isFullscreen || false)
|
||||||
|
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||||
|
|
||||||
|
// Expand WebApp to full height
|
||||||
|
webApp.expand()
|
||||||
|
webApp.ready()
|
||||||
|
|
||||||
|
// Listen for fullscreen changes
|
||||||
|
const handleFullscreenChanged = () => {
|
||||||
|
setIsFullscreen(webApp.isFullscreen || false)
|
||||||
|
setSafeAreaInset(webApp.safeAreaInset || { top: 0, bottom: 0, left: 0, right: 0 })
|
||||||
|
}
|
||||||
|
|
||||||
|
webApp.onEvent('fullscreenChanged', handleFullscreenChanged)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
webApp.offEvent('fullscreenChanged', handleFullscreenChanged)
|
||||||
|
}
|
||||||
|
}, [webApp])
|
||||||
|
|
||||||
|
const requestFullscreen = useCallback(() => {
|
||||||
|
if (webApp?.requestFullscreen) {
|
||||||
|
try {
|
||||||
|
webApp.requestFullscreen()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Fullscreen not supported:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [webApp])
|
||||||
|
|
||||||
|
const exitFullscreen = useCallback(() => {
|
||||||
|
if (webApp?.exitFullscreen) {
|
||||||
|
try {
|
||||||
|
webApp.exitFullscreen()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Exit fullscreen failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [webApp])
|
||||||
|
|
||||||
|
const toggleFullscreen = useCallback(() => {
|
||||||
|
if (isFullscreen) {
|
||||||
|
exitFullscreen()
|
||||||
|
} else {
|
||||||
|
requestFullscreen()
|
||||||
|
}
|
||||||
|
}, [isFullscreen, requestFullscreen, exitFullscreen])
|
||||||
|
|
||||||
|
const disableVerticalSwipes = useCallback(() => {
|
||||||
|
if (webApp?.disableVerticalSwipes) {
|
||||||
|
webApp.disableVerticalSwipes()
|
||||||
|
}
|
||||||
|
}, [webApp])
|
||||||
|
|
||||||
|
const enableVerticalSwipes = useCallback(() => {
|
||||||
|
if (webApp?.enableVerticalSwipes) {
|
||||||
|
webApp.enableVerticalSwipes()
|
||||||
|
}
|
||||||
|
}, [webApp])
|
||||||
|
|
||||||
|
// Check if fullscreen is supported (Bot API 8.0+)
|
||||||
|
const isFullscreenSupported = Boolean(webApp?.requestFullscreen)
|
||||||
|
|
||||||
|
return {
|
||||||
|
isTelegramWebApp,
|
||||||
|
isFullscreen,
|
||||||
|
isFullscreenSupported,
|
||||||
|
safeAreaInset,
|
||||||
|
requestFullscreen,
|
||||||
|
exitFullscreen,
|
||||||
|
toggleFullscreen,
|
||||||
|
disableVerticalSwipes,
|
||||||
|
enableVerticalSwipes,
|
||||||
|
webApp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Telegram WebApp on app start
|
||||||
|
* Call this in main.tsx or App.tsx
|
||||||
|
*/
|
||||||
|
export function initTelegramWebApp() {
|
||||||
|
const webApp = window.Telegram?.WebApp
|
||||||
|
if (webApp) {
|
||||||
|
webApp.ready()
|
||||||
|
webApp.expand()
|
||||||
|
|
||||||
|
// Disable vertical swipes to prevent accidental closing
|
||||||
|
if (webApp.disableVerticalSwipes) {
|
||||||
|
webApp.disableVerticalSwipes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,13 @@ import App from './App'
|
|||||||
import { ThemeColorsProvider } from './providers/ThemeColorsProvider'
|
import { ThemeColorsProvider } from './providers/ThemeColorsProvider'
|
||||||
import { ToastProvider } from './components/Toast'
|
import { ToastProvider } from './components/Toast'
|
||||||
import { initLogoPreload } from './api/branding'
|
import { initLogoPreload } from './api/branding'
|
||||||
|
import { initTelegramWebApp } from './hooks/useTelegramWebApp'
|
||||||
import './i18n'
|
import './i18n'
|
||||||
import './styles/globals.css'
|
import './styles/globals.css'
|
||||||
|
|
||||||
|
// Initialize Telegram WebApp (expand, disable swipes)
|
||||||
|
initTelegramWebApp()
|
||||||
|
|
||||||
// Preload logo from cache immediately on page load
|
// Preload logo from cache immediately on page load
|
||||||
initLogoPreload()
|
initLogoPreload()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ import FortuneWheel from '../components/wheel/FortuneWheel'
|
|||||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'
|
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt'
|
||||||
import { useCurrency } from '../hooks/useCurrency'
|
import { useCurrency } from '../hooks/useCurrency'
|
||||||
|
|
||||||
|
// Pre-calculated confetti positions (stable across re-renders)
|
||||||
|
const CONFETTI_POSITIONS = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
color: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
|
||||||
|
left: `${(i * 17 + 5) % 95}%`,
|
||||||
|
top: `${(i * 23 + 3) % 90}%`,
|
||||||
|
delay: `${(i * 0.1) % 2}s`,
|
||||||
|
duration: `${1 + (i % 3) * 0.3}s`,
|
||||||
|
}))
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const StarIcon = () => (
|
const StarIcon = () => (
|
||||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -98,10 +107,12 @@ export default function Wheel() {
|
|||||||
}, [config, isTelegramMiniApp])
|
}, [config, isTelegramMiniApp])
|
||||||
|
|
||||||
// Function to poll for new spin result after Stars payment
|
// Function to poll for new spin result after Stars payment
|
||||||
const pollForSpinResult = useCallback(async (maxAttempts = 15, delayMs = 800) => {
|
const pollForSpinResult = useCallback(async (signal: AbortSignal, maxAttempts = 15, delayMs = 800) => {
|
||||||
// Wait a bit before first poll to give the bot time to process the payment
|
// Wait a bit before first poll to give the bot time to process the payment
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
|
|
||||||
|
if (signal.aborted) return null
|
||||||
|
|
||||||
// Get current history to find the latest spin ID
|
// Get current history to find the latest spin ID
|
||||||
let historyBefore
|
let historyBefore
|
||||||
try {
|
try {
|
||||||
@@ -112,8 +123,12 @@ export default function Wheel() {
|
|||||||
const lastSpinIdBefore = historyBefore.items.length > 0 ? historyBefore.items[0].id : 0
|
const lastSpinIdBefore = historyBefore.items.length > 0 ? historyBefore.items[0].id : 0
|
||||||
|
|
||||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||||
|
if (signal.aborted) return null
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||||
|
|
||||||
|
if (signal.aborted) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const historyAfter = await wheelApi.getHistory(1, 1)
|
const historyAfter = await wheelApi.getHistory(1, 1)
|
||||||
|
|
||||||
@@ -152,6 +167,16 @@ export default function Wheel() {
|
|||||||
// Ref to store pending Stars payment result
|
// Ref to store pending Stars payment result
|
||||||
const pendingStarsResultRef = useRef<SpinResult | null>(null)
|
const pendingStarsResultRef = useRef<SpinResult | null>(null)
|
||||||
const isStarsSpinRef = useRef(false)
|
const isStarsSpinRef = useRef(false)
|
||||||
|
const pollingAbortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
// Cleanup polling on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (pollingAbortRef.current) {
|
||||||
|
pollingAbortRef.current.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const starsInvoiceMutation = useMutation({
|
const starsInvoiceMutation = useMutation({
|
||||||
mutationFn: wheelApi.createStarsInvoice,
|
mutationFn: wheelApi.createStarsInvoice,
|
||||||
@@ -163,6 +188,12 @@ export default function Wheel() {
|
|||||||
isStarsSpinRef.current = true
|
isStarsSpinRef.current = true
|
||||||
pendingStarsResultRef.current = null
|
pendingStarsResultRef.current = null
|
||||||
|
|
||||||
|
// Cancel any existing polling
|
||||||
|
if (pollingAbortRef.current) {
|
||||||
|
pollingAbortRef.current.abort()
|
||||||
|
}
|
||||||
|
pollingAbortRef.current = new AbortController()
|
||||||
|
|
||||||
// Payment done - reset paying state immediately
|
// Payment done - reset paying state immediately
|
||||||
setIsPayingStars(false)
|
setIsPayingStars(false)
|
||||||
|
|
||||||
@@ -172,7 +203,10 @@ export default function Wheel() {
|
|||||||
|
|
||||||
// Poll for the result in the background - don't await here!
|
// Poll for the result in the background - don't await here!
|
||||||
// The result will be stored and shown when animation completes
|
// The result will be stored and shown when animation completes
|
||||||
pollForSpinResult().then((result) => {
|
const abortSignal = pollingAbortRef.current.signal
|
||||||
|
pollForSpinResult(abortSignal).then((result) => {
|
||||||
|
if (abortSignal.aborted) return
|
||||||
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
|
queryClient.invalidateQueries({ queryKey: ['wheel-config'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
|
queryClient.invalidateQueries({ queryKey: ['wheel-history'] })
|
||||||
|
|
||||||
@@ -195,6 +229,8 @@ export default function Wheel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
|
if (abortSignal.aborted) return
|
||||||
|
|
||||||
// Error polling, show generic success
|
// Error polling, show generic success
|
||||||
pendingStarsResultRef.current = {
|
pendingStarsResultRef.current = {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -551,7 +587,7 @@ export default function Wheel() {
|
|||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundSize: '200% 100%',
|
backgroundSize: '200% 100%',
|
||||||
animation: !isSpinning && config.can_spin ? 'shimmer 3s linear infinite' : 'none',
|
animation: !isSpinning && config.can_spin ? 'wheel-shimmer 3s linear infinite' : 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Button glow effect */}
|
{/* Button glow effect */}
|
||||||
@@ -669,18 +705,18 @@ export default function Wheel() {
|
|||||||
<>
|
<>
|
||||||
<div className="absolute top-0 left-0 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl" />
|
<div className="absolute top-0 left-0 w-32 h-32 bg-purple-500/20 rounded-full blur-3xl" />
|
||||||
<div className="absolute bottom-0 right-0 w-32 h-32 bg-indigo-500/20 rounded-full blur-3xl" />
|
<div className="absolute bottom-0 right-0 w-32 h-32 bg-indigo-500/20 rounded-full blur-3xl" />
|
||||||
{/* Confetti effect */}
|
{/* Confetti effect - using pre-calculated positions */}
|
||||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||||
{Array.from({ length: 20 }).map((_, i) => (
|
{CONFETTI_POSITIONS.map((pos, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="absolute w-2 h-2 rounded-full animate-bounce"
|
className="absolute w-2 h-2 rounded-full animate-bounce"
|
||||||
style={{
|
style={{
|
||||||
background: ['#fbbf24', '#a855f7', '#3b82f6', '#10b981', '#f43f5e'][i % 5],
|
background: pos.color,
|
||||||
left: `${Math.random() * 100}%`,
|
left: pos.left,
|
||||||
top: `${Math.random() * 100}%`,
|
top: pos.top,
|
||||||
animationDelay: `${Math.random() * 2}s`,
|
animationDelay: pos.delay,
|
||||||
animationDuration: `${1 + Math.random()}s`,
|
animationDuration: pos.duration,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -747,12 +783,6 @@ export default function Wheel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<style>{`
|
|
||||||
@keyframes shimmer {
|
|
||||||
0% { background-position: 200% 50%; }
|
|
||||||
100% { background-position: -200% 50%; }
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1003,6 +1003,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Fortune Wheel animations */
|
/* Fortune Wheel animations */
|
||||||
|
@keyframes wheel-shimmer {
|
||||||
|
0% { background-position: 200% 50%; }
|
||||||
|
100% { background-position: -200% 50%; }
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes wheel-glow {
|
@keyframes wheel-glow {
|
||||||
0%, 100% { filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.3)); }
|
0%, 100% { filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.3)); }
|
||||||
50% { filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.6)); }
|
50% { filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.6)); }
|
||||||
|
|||||||
23
src/vite-env.d.ts
vendored
23
src/vite-env.d.ts
vendored
@@ -25,12 +25,35 @@ interface TelegramWebApp {
|
|||||||
auth_date: number
|
auth_date: number
|
||||||
hash: string
|
hash: string
|
||||||
}
|
}
|
||||||
|
version: string
|
||||||
|
platform: string
|
||||||
|
isExpanded: boolean
|
||||||
|
isClosingConfirmationEnabled: boolean
|
||||||
|
isVerticalSwipesEnabled: boolean
|
||||||
|
isFullscreen: boolean
|
||||||
|
isOrientationLocked: boolean
|
||||||
|
safeAreaInset: { top: number; bottom: number; left: number; right: number }
|
||||||
|
contentSafeAreaInset: { top: number; bottom: number; left: number; right: number }
|
||||||
ready: () => void
|
ready: () => void
|
||||||
expand: () => void
|
expand: () => void
|
||||||
close: () => void
|
close: () => void
|
||||||
openLink: (url: string, options?: { try_instant_view?: boolean; try_browser?: boolean }) => void
|
openLink: (url: string, options?: { try_instant_view?: boolean; try_browser?: boolean }) => void
|
||||||
openTelegramLink: (url: string) => void
|
openTelegramLink: (url: string) => void
|
||||||
openInvoice: (url: string, callback?: (status: 'paid' | 'cancelled' | 'failed' | 'pending') => void) => void
|
openInvoice: (url: string, callback?: (status: 'paid' | 'cancelled' | 'failed' | 'pending') => void) => void
|
||||||
|
// Fullscreen API (Bot API 8.0+)
|
||||||
|
requestFullscreen: () => void
|
||||||
|
exitFullscreen: () => void
|
||||||
|
lockOrientation: () => void
|
||||||
|
unlockOrientation: () => void
|
||||||
|
// Vertical swipes control
|
||||||
|
disableVerticalSwipes: () => void
|
||||||
|
enableVerticalSwipes: () => void
|
||||||
|
// Closing confirmation
|
||||||
|
enableClosingConfirmation: () => void
|
||||||
|
disableClosingConfirmation: () => void
|
||||||
|
// Event handlers
|
||||||
|
onEvent: (eventType: string, callback: () => void) => void
|
||||||
|
offEvent: (eventType: string, callback: () => void) => void
|
||||||
MainButton: {
|
MainButton: {
|
||||||
text: string
|
text: string
|
||||||
color: string
|
color: string
|
||||||
|
|||||||
Reference in New Issue
Block a user