mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
22
src/App.tsx
22
src/App.tsx
@@ -26,6 +26,8 @@ const Contests = lazy(() => import('./pages/Contests'));
|
|||||||
const Polls = lazy(() => import('./pages/Polls'));
|
const Polls = lazy(() => import('./pages/Polls'));
|
||||||
const Info = lazy(() => import('./pages/Info'));
|
const Info = lazy(() => import('./pages/Info'));
|
||||||
const Wheel = lazy(() => import('./pages/Wheel'));
|
const Wheel = lazy(() => import('./pages/Wheel'));
|
||||||
|
const TopUpMethodSelect = lazy(() => import('./pages/TopUpMethodSelect'));
|
||||||
|
const TopUpAmount = lazy(() => import('./pages/TopUpAmount'));
|
||||||
|
|
||||||
// Admin pages - lazy load (only for admins)
|
// Admin pages - lazy load (only for admins)
|
||||||
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
|
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
|
||||||
@@ -170,6 +172,26 @@ function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="/balance/top-up"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<TopUpMethodSelect />
|
||||||
|
</LazyPage>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/balance/top-up/:methodId"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute>
|
||||||
|
<LazyPage>
|
||||||
|
<TopUpAmount />
|
||||||
|
</LazyPage>
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="/referral"
|
path="/referral"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { isInTelegramWebApp } from '@/hooks/useTelegramSDK';
|
|
||||||
|
|
||||||
interface ColorPickerProps {
|
interface ColorPickerProps {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -111,9 +110,6 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
|
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const pickerRef = useRef<HTMLDivElement>(null);
|
const pickerRef = useRef<HTMLDivElement>(null);
|
||||||
const colorInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const isTelegram = useMemo(() => isInTelegramWebApp(), []);
|
|
||||||
|
|
||||||
// Sync with external value
|
// Sync with external value
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -177,18 +173,15 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleScroll = () => handleClose();
|
|
||||||
const handleResize = () => updatePosition();
|
const handleResize = () => updatePosition();
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
document.addEventListener('touchstart', handleClickOutside as EventListener);
|
document.addEventListener('touchstart', handleClickOutside as EventListener);
|
||||||
window.addEventListener('scroll', handleScroll, true);
|
|
||||||
window.addEventListener('resize', handleResize);
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
document.removeEventListener('touchstart', handleClickOutside as EventListener);
|
document.removeEventListener('touchstart', handleClickOutside as EventListener);
|
||||||
window.removeEventListener('scroll', handleScroll, true);
|
|
||||||
window.removeEventListener('resize', handleResize);
|
window.removeEventListener('resize', handleResize);
|
||||||
};
|
};
|
||||||
}, [isOpen, handleClose, updatePosition]);
|
}, [isOpen, handleClose, updatePosition]);
|
||||||
@@ -229,18 +222,6 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
[hsl, updateColorFromHsl],
|
[hsl, updateColorFromHsl],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle native color input
|
|
||||||
const handleColorInputChange = useCallback(
|
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const newColor = e.target.value;
|
|
||||||
setLocalValue(newColor);
|
|
||||||
const rgb = hexToRgb(newColor);
|
|
||||||
setHsl(rgbToHsl(rgb.r, rgb.g, rgb.b));
|
|
||||||
onChange(newColor);
|
|
||||||
},
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle hex input
|
// Handle hex input
|
||||||
const handleHexInputChange = useCallback(
|
const handleHexInputChange = useCallback(
|
||||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
@@ -409,41 +390,6 @@ export function ColorPicker({ value, onChange, label, description, disabled }: C
|
|||||||
placeholder="#000000"
|
placeholder="#000000"
|
||||||
maxLength={7}
|
maxLength={7}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Native color picker button (hidden in Telegram) */}
|
|
||||||
{!isTelegram && (
|
|
||||||
<>
|
|
||||||
<input
|
|
||||||
ref={colorInputRef}
|
|
||||||
type="color"
|
|
||||||
value={localValue || '#000000'}
|
|
||||||
onChange={handleColorInputChange}
|
|
||||||
disabled={disabled}
|
|
||||||
className="sr-only"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => colorInputRef.current?.click()}
|
|
||||||
disabled={disabled}
|
|
||||||
className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-xl border border-dark-700 bg-dark-800 text-dark-400 transition-colors hover:bg-dark-700 hover:text-dark-200 disabled:opacity-50"
|
|
||||||
title="System color picker"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={1.5}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.879 2.88M6.75 17.25h.008v.008H6.75v-.008z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Render picker in portal */}
|
{/* Render picker in portal */}
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { balanceApi } from '../api/balance';
|
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
|
||||||
import TopUpModal from './TopUpModal';
|
|
||||||
import type { PaymentMethod } from '../types';
|
|
||||||
|
|
||||||
interface InsufficientBalancePromptProps {
|
interface InsufficientBalancePromptProps {
|
||||||
/** Amount missing in kopeks */
|
/** Amount missing in kopeks */
|
||||||
@@ -28,49 +24,33 @@ export default function InsufficientBalancePrompt({
|
|||||||
onBeforeTopUp,
|
onBeforeTopUp,
|
||||||
}: InsufficientBalancePromptProps) {
|
}: InsufficientBalancePromptProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
const [showMethodSelect, setShowMethodSelect] = useState(false);
|
|
||||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
|
|
||||||
const [isPreparingTopUp, setIsPreparingTopUp] = useState(false);
|
const [isPreparingTopUp, setIsPreparingTopUp] = useState(false);
|
||||||
|
|
||||||
// Auto-close modals when success notification appears
|
|
||||||
const handleCloseAll = useCallback(() => {
|
|
||||||
setShowMethodSelect(false);
|
|
||||||
setSelectedMethod(null);
|
|
||||||
}, []);
|
|
||||||
useCloseOnSuccessNotification(handleCloseAll);
|
|
||||||
|
|
||||||
const { data: paymentMethods } = useQuery({
|
|
||||||
queryKey: ['payment-methods'],
|
|
||||||
queryFn: balanceApi.getPaymentMethods,
|
|
||||||
enabled: showMethodSelect,
|
|
||||||
});
|
|
||||||
|
|
||||||
const missingRubles = missingAmountKopeks / 100;
|
const missingRubles = missingAmountKopeks / 100;
|
||||||
const displayAmount = formatAmount(missingRubles);
|
const displayAmount = formatAmount(missingRubles);
|
||||||
|
|
||||||
const handleMethodSelect = (method: PaymentMethod) => {
|
|
||||||
setSelectedMethod(method);
|
|
||||||
setShowMethodSelect(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTopUpClick = async () => {
|
const handleTopUpClick = async () => {
|
||||||
if (onBeforeTopUp) {
|
if (onBeforeTopUp) {
|
||||||
setIsPreparingTopUp(true);
|
setIsPreparingTopUp(true);
|
||||||
try {
|
try {
|
||||||
await onBeforeTopUp();
|
await onBeforeTopUp();
|
||||||
} catch {
|
} catch {
|
||||||
// Silently ignore errors - still open the modal
|
// Silently ignore errors - still navigate
|
||||||
} finally {
|
} finally {
|
||||||
setIsPreparingTopUp(false);
|
setIsPreparingTopUp(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setShowMethodSelect(true);
|
const params = new URLSearchParams();
|
||||||
|
params.set('amount', String(Math.ceil(missingRubles)));
|
||||||
|
params.set('returnTo', location.pathname);
|
||||||
|
navigate(`/balance/top-up?${params.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (compact) {
|
if (compact) {
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3 ${className}`}
|
className={`flex items-center justify-between gap-3 rounded-xl border border-error-500/30 bg-error-500/10 p-3 ${className}`}
|
||||||
>
|
>
|
||||||
@@ -107,28 +87,10 @@ export default function InsufficientBalancePrompt({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showMethodSelect && (
|
|
||||||
<PaymentMethodModal
|
|
||||||
paymentMethods={paymentMethods}
|
|
||||||
onSelect={handleMethodSelect}
|
|
||||||
onClose={() => setShowMethodSelect(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedMethod && (
|
|
||||||
<TopUpModal
|
|
||||||
method={selectedMethod}
|
|
||||||
initialAmountRubles={Math.ceil(missingRubles)}
|
|
||||||
onClose={() => setSelectedMethod(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div
|
<div
|
||||||
className={`rounded-xl border border-error-500/30 bg-gradient-to-br from-error-500/10 to-warning-500/5 p-4 ${className}`}
|
className={`rounded-xl border border-error-500/30 bg-gradient-to-br from-error-500/10 to-warning-500/5 p-4 ${className}`}
|
||||||
>
|
>
|
||||||
@@ -184,132 +146,5 @@ export default function InsufficientBalancePrompt({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showMethodSelect && (
|
|
||||||
<PaymentMethodModal
|
|
||||||
paymentMethods={paymentMethods}
|
|
||||||
onSelect={handleMethodSelect}
|
|
||||||
onClose={() => setShowMethodSelect(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedMethod && (
|
|
||||||
<TopUpModal
|
|
||||||
method={selectedMethod}
|
|
||||||
initialAmountRubles={Math.ceil(missingRubles)}
|
|
||||||
onClose={() => setSelectedMethod(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PaymentMethodModalProps {
|
|
||||||
paymentMethods: PaymentMethod[] | undefined;
|
|
||||||
onSelect: (method: PaymentMethod) => void;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PaymentMethodModal({ paymentMethods, onSelect, onClose }: PaymentMethodModalProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 px-4 pb-28 pt-14 backdrop-blur-sm sm:pb-0 sm:pt-0">
|
|
||||||
<div className="absolute inset-0" onClick={onClose} />
|
|
||||||
|
|
||||||
<div className="relative flex max-h-full w-full max-w-sm flex-col overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900/95 shadow-2xl backdrop-blur-xl">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between bg-dark-800/50 px-4 py-3">
|
|
||||||
<span className="font-semibold text-dark-100">{t('balance.selectPaymentMethod')}</span>
|
|
||||||
<button
|
|
||||||
onClick={onClose}
|
|
||||||
className="rounded-lg p-1.5 text-dark-400 hover:bg-dark-700"
|
|
||||||
aria-label="Close"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="h-5 w-5"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="flex-1 space-y-2 overflow-y-auto p-3">
|
|
||||||
{!paymentMethods ? (
|
|
||||||
<div className="flex items-center justify-center py-8">
|
|
||||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
|
||||||
</div>
|
|
||||||
) : paymentMethods.length === 0 ? (
|
|
||||||
<div className="py-6 text-center text-sm text-dark-400">
|
|
||||||
{t('balance.noPaymentMethods')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
paymentMethods.map((method) => {
|
|
||||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
|
||||||
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
|
|
||||||
defaultValue: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={method.id}
|
|
||||||
disabled={!method.is_available}
|
|
||||||
onClick={() => method.is_available && onSelect(method)}
|
|
||||||
className={`flex w-full items-center gap-3 rounded-xl p-3 text-left ${
|
|
||||||
method.is_available
|
|
||||||
? 'bg-dark-800 hover:bg-dark-700 active:bg-dark-600'
|
|
||||||
: 'bg-dark-800/50 opacity-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-lg bg-accent-500/20">
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 text-accent-400"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 004.5 19.5z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium text-dark-100">
|
|
||||||
{translatedName || method.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-dark-500">
|
|
||||||
{formatAmount(method.min_amount_kopeks / 100, 0)} –{' '}
|
|
||||||
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<svg
|
|
||||||
className="h-4 w-4 text-dark-500"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth={2}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M8.25 4.5l7.5 7.5-7.5 7.5"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,29 +98,29 @@ function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) {
|
|||||||
const typeStyles = {
|
const typeStyles = {
|
||||||
success: {
|
success: {
|
||||||
bg: 'bg-dark-800',
|
bg: 'bg-dark-800',
|
||||||
accent: 'bg-gradient-to-r from-success-500/30 to-transparent',
|
accent: 'bg-gradient-to-r from-success-500/50 to-transparent',
|
||||||
border: 'border-success-500/50',
|
border: 'border-success-500/70',
|
||||||
icon: 'text-success-400',
|
icon: 'text-success-400',
|
||||||
iconBg: 'bg-success-500/30',
|
iconBg: 'bg-success-500/30',
|
||||||
},
|
},
|
||||||
error: {
|
error: {
|
||||||
bg: 'bg-dark-800',
|
bg: 'bg-dark-800',
|
||||||
accent: 'bg-gradient-to-r from-error-500/30 to-transparent',
|
accent: 'bg-gradient-to-r from-error-500/50 to-transparent',
|
||||||
border: 'border-error-500/50',
|
border: 'border-error-500/70',
|
||||||
icon: 'text-error-400',
|
icon: 'text-error-400',
|
||||||
iconBg: 'bg-error-500/30',
|
iconBg: 'bg-error-500/30',
|
||||||
},
|
},
|
||||||
warning: {
|
warning: {
|
||||||
bg: 'bg-dark-800',
|
bg: 'bg-dark-800',
|
||||||
accent: 'bg-gradient-to-r from-warning-500/30 to-transparent',
|
accent: 'bg-gradient-to-r from-warning-500/50 to-transparent',
|
||||||
border: 'border-warning-500/50',
|
border: 'border-warning-500/70',
|
||||||
icon: 'text-warning-400',
|
icon: 'text-warning-400',
|
||||||
iconBg: 'bg-warning-500/30',
|
iconBg: 'bg-warning-500/30',
|
||||||
},
|
},
|
||||||
info: {
|
info: {
|
||||||
bg: 'bg-dark-800',
|
bg: 'bg-dark-800',
|
||||||
accent: 'bg-gradient-to-r from-accent-500/30 to-transparent',
|
accent: 'bg-gradient-to-r from-accent-500/50 to-transparent',
|
||||||
border: 'border-accent-500/50',
|
border: 'border-accent-500/70',
|
||||||
icon: 'text-accent-400',
|
icon: 'text-accent-400',
|
||||||
iconBg: 'bg-accent-500/30',
|
iconBg: 'bg-accent-500/30',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -145,7 +145,10 @@ export function AppHeader({
|
|||||||
};
|
};
|
||||||
}, [mobileMenuOpen]);
|
}, [mobileMenuOpen]);
|
||||||
|
|
||||||
const isActive = (path: string) => location.pathname === path;
|
const isActive = (path: string) => {
|
||||||
|
if (path === '/') return location.pathname === '/';
|
||||||
|
return location.pathname.startsWith(path);
|
||||||
|
};
|
||||||
const isAdminActive = () => location.pathname.startsWith('/admin');
|
const isAdminActive = () => location.pathname.startsWith('/admin');
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
|
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
|
||||||
import { brandingApi } from '@/api/branding';
|
import { brandingApi } from '@/api/branding';
|
||||||
|
import { themeColorsApi } from '@/api/themeColors';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { ThemeSettings, DEFAULT_THEME_COLORS } from '@/types/theme';
|
import { DEFAULT_THEME_COLORS } from '@/types/theme';
|
||||||
|
|
||||||
const VERT = /* glsl */ `#version 300 es
|
const VERT = /* glsl */ `#version 300 es
|
||||||
in vec2 position;
|
in vec2 position;
|
||||||
@@ -107,8 +108,23 @@ function hexToRgb(hex: string): [number, number, number] {
|
|||||||
return [r, g, b];
|
return [r, g, b];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reduce lightness of a hex color for subdued background blobs
|
||||||
|
function dimAccent(hex: string, factor = 0.45): string {
|
||||||
|
hex = hex.replace('#', '');
|
||||||
|
if (hex.length === 3) {
|
||||||
|
hex = hex
|
||||||
|
.split('')
|
||||||
|
.map((c) => c + c)
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
const r = Math.round(parseInt(hex.substring(0, 2), 16) * factor);
|
||||||
|
const g = Math.round(parseInt(hex.substring(2, 4), 16) * factor);
|
||||||
|
const b = Math.round(parseInt(hex.substring(4, 6), 16) * factor);
|
||||||
|
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
function generateColorStops(background: string, surface: string, accent: string): string[] {
|
function generateColorStops(background: string, surface: string, accent: string): string[] {
|
||||||
return [background, surface, accent];
|
return [background, surface, dimAccent(accent)];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Aurora() {
|
export function Aurora() {
|
||||||
@@ -117,8 +133,6 @@ export function Aurora() {
|
|||||||
const rendererRef = useRef<Renderer | null>(null);
|
const rendererRef = useRef<Renderer | null>(null);
|
||||||
const programRef = useRef<Program | null>(null);
|
const programRef = useRef<Program | null>(null);
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
// Fetch animation setting
|
// Fetch animation setting
|
||||||
const { data: animationSetting } = useQuery({
|
const { data: animationSetting } = useQuery({
|
||||||
queryKey: ['animation-enabled'],
|
queryKey: ['animation-enabled'],
|
||||||
@@ -128,15 +142,19 @@ export function Aurora() {
|
|||||||
|
|
||||||
const isEnabled = animationSetting?.enabled ?? false;
|
const isEnabled = animationSetting?.enabled ?? false;
|
||||||
|
|
||||||
// Get theme colors from cache (already fetched by ThemeColorsProvider)
|
// Subscribe reactively to theme-colors cache so Aurora re-renders on setQueryData
|
||||||
const themeColors =
|
const { data: themeColors = DEFAULT_THEME_COLORS } = useQuery({
|
||||||
queryClient.getQueryData<ThemeSettings>(['theme-colors']) || DEFAULT_THEME_COLORS;
|
queryKey: ['theme-colors'],
|
||||||
|
queryFn: themeColorsApi.getColors,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
// Pick background and surface based on current theme
|
// Pick background and surface based on current theme
|
||||||
const { isDark } = useTheme();
|
const { isDark } = useTheme();
|
||||||
const background = isDark ? themeColors.darkBackground : themeColors.lightBackground;
|
const background = isDark ? themeColors.darkBackground : themeColors.lightBackground;
|
||||||
const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface;
|
const surface = isDark ? themeColors.darkSurface : themeColors.lightSurface;
|
||||||
|
|
||||||
|
// Initialize WebGL context once (only depends on isEnabled)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEnabled || !containerRef.current) return;
|
if (!isEnabled || !containerRef.current) return;
|
||||||
|
|
||||||
@@ -220,7 +238,20 @@ export function Aurora() {
|
|||||||
rendererRef.current = null;
|
rendererRef.current = null;
|
||||||
programRef.current = null;
|
programRef.current = null;
|
||||||
};
|
};
|
||||||
}, [isEnabled, themeColors.accent, background, surface]);
|
}, [isEnabled]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Update color uniforms reactively without recreating WebGL context
|
||||||
|
useEffect(() => {
|
||||||
|
if (!programRef.current) return;
|
||||||
|
const colorStops = generateColorStops(background, surface, themeColors.accent);
|
||||||
|
const colorStopsArray = colorStops
|
||||||
|
.map((hex) => {
|
||||||
|
const c = new Color(hex);
|
||||||
|
return [c.r, c.g, c.b];
|
||||||
|
})
|
||||||
|
.flat();
|
||||||
|
programRef.current.uniforms.uColorStops.value = colorStopsArray;
|
||||||
|
}, [themeColors.accent, background, surface]);
|
||||||
|
|
||||||
if (!isEnabled) {
|
if (!isEnabled) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -713,6 +713,9 @@
|
|||||||
"starsPaymentSuccessCheckHistory": "Payment successful! Check history or Telegram for the result.",
|
"starsPaymentSuccessCheckHistory": "Payment successful! Check history or Telegram for the result.",
|
||||||
"starsPaymentFailed": "Payment failed. Please try again.",
|
"starsPaymentFailed": "Payment failed. Please try again.",
|
||||||
"starsPaymentRedirected": "Telegram will open for payment. Refresh the page after payment.",
|
"starsPaymentRedirected": "Telegram will open for payment. Refresh the page after payment.",
|
||||||
|
"starsNotAvailable": "Star payments are currently unavailable. Contact support.",
|
||||||
|
"confirmStarsPayment": "You will be redirected to Telegram to pay for one wheel spin.",
|
||||||
|
"payStars": "Pay {{count}} ⭐",
|
||||||
"youWon": "You won",
|
"youWon": "You won",
|
||||||
"noPrize": "No luck this time...",
|
"noPrize": "No luck this time...",
|
||||||
"noHistory": "No spin history yet",
|
"noHistory": "No spin history yet",
|
||||||
@@ -977,6 +980,7 @@
|
|||||||
"promocodes": "Promocodes",
|
"promocodes": "Promocodes",
|
||||||
"promoPrefix": "Promo code prefix"
|
"promoPrefix": "Promo code prefix"
|
||||||
},
|
},
|
||||||
|
"starsNotEnabledGlobally": "Star payments are not enabled in Payment Methods. Enable \"Telegram Stars\" in the Payment Methods section.",
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "Add Prize",
|
"addPrize": "Add Prize",
|
||||||
"editPrize": "Edit Prize",
|
"editPrize": "Edit Prize",
|
||||||
|
|||||||
@@ -599,6 +599,9 @@
|
|||||||
"starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.",
|
"starsPaymentSuccess": "پرداخت موفق! نتیجه به تلگرام ارسال شد.",
|
||||||
"starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.",
|
"starsPaymentFailed": "پرداخت ناموفق. دوباره تلاش کنید.",
|
||||||
"starsPaymentRedirected": "تلگرام برای پرداخت باز میشود. پس از پرداخت صفحه را بازخوانی کنید.",
|
"starsPaymentRedirected": "تلگرام برای پرداخت باز میشود. پس از پرداخت صفحه را بازخوانی کنید.",
|
||||||
|
"starsNotAvailable": "پرداخت با ستاره در حال حاضر در دسترس نیست. با پشتیبانی تماس بگیرید.",
|
||||||
|
"confirmStarsPayment": "شما به تلگرام برای پرداخت یک چرخش منتقل خواهید شد.",
|
||||||
|
"payStars": "پرداخت {{count}} ⭐",
|
||||||
"noHistory": "هنوز تاریخچهای نیست",
|
"noHistory": "هنوز تاریخچهای نیست",
|
||||||
"banner": {
|
"banner": {
|
||||||
"title": "شانس خود را امتحان کنید!",
|
"title": "شانس خود را امتحان کنید!",
|
||||||
@@ -699,6 +702,7 @@
|
|||||||
"limitsAndRtp": "محدودیتها و RTP",
|
"limitsAndRtp": "محدودیتها و RTP",
|
||||||
"promocodes": "کدهای تخفیف"
|
"promocodes": "کدهای تخفیف"
|
||||||
},
|
},
|
||||||
|
"starsNotEnabledGlobally": "پرداخت با ستاره در روشهای پرداخت فعال نیست. «Telegram Stars» را در بخش «روشهای پرداخت» فعال کنید.",
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "افزودن جایزه",
|
"addPrize": "افزودن جایزه",
|
||||||
"editPrize": "ویرایش جایزه",
|
"editPrize": "ویرایش جایزه",
|
||||||
|
|||||||
@@ -741,6 +741,9 @@
|
|||||||
"starsPaymentSuccessCheckHistory": "Оплата прошла! Проверьте историю или Telegram для результата.",
|
"starsPaymentSuccessCheckHistory": "Оплата прошла! Проверьте историю или Telegram для результата.",
|
||||||
"starsPaymentFailed": "Ошибка оплаты. Попробуйте еще раз.",
|
"starsPaymentFailed": "Ошибка оплаты. Попробуйте еще раз.",
|
||||||
"starsPaymentRedirected": "Откроется Telegram для оплаты. После оплаты обновите страницу.",
|
"starsPaymentRedirected": "Откроется Telegram для оплаты. После оплаты обновите страницу.",
|
||||||
|
"starsNotAvailable": "Оплата звёздами сейчас недоступна. Обратитесь в поддержку.",
|
||||||
|
"confirmStarsPayment": "Вы будете перенаправлены в Telegram для оплаты одного вращения колеса.",
|
||||||
|
"payStars": "Оплатить {{count}} ⭐",
|
||||||
"youWon": "Вы выиграли",
|
"youWon": "Вы выиграли",
|
||||||
"noPrize": "В этот раз не повезло...",
|
"noPrize": "В этот раз не повезло...",
|
||||||
"noHistory": "История вращений пуста",
|
"noHistory": "История вращений пуста",
|
||||||
@@ -1001,6 +1004,7 @@
|
|||||||
"promocodes": "Промокоды",
|
"promocodes": "Промокоды",
|
||||||
"promoPrefix": "Префикс промокодов"
|
"promoPrefix": "Префикс промокодов"
|
||||||
},
|
},
|
||||||
|
"starsNotEnabledGlobally": "Оплата звёздами не включена в платёжных методах. Включите метод «Telegram Stars» в разделе «Платёжные методы».",
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "Добавить приз",
|
"addPrize": "Добавить приз",
|
||||||
"editPrize": "Редактировать приз",
|
"editPrize": "Редактировать приз",
|
||||||
|
|||||||
@@ -603,6 +603,9 @@
|
|||||||
"starsPaymentSuccess": "支付成功!抽奖结果已发送到Telegram。",
|
"starsPaymentSuccess": "支付成功!抽奖结果已发送到Telegram。",
|
||||||
"starsPaymentFailed": "支付失败。请重试。",
|
"starsPaymentFailed": "支付失败。请重试。",
|
||||||
"starsPaymentRedirected": "将打开Telegram进行支付。支付后请刷新页面。",
|
"starsPaymentRedirected": "将打开Telegram进行支付。支付后请刷新页面。",
|
||||||
|
"starsNotAvailable": "星星支付暂不可用。请联系客服。",
|
||||||
|
"confirmStarsPayment": "您将被重定向到Telegram进行一次转盘支付。",
|
||||||
|
"payStars": "支付 {{count}} ⭐",
|
||||||
"noHistory": "暂无抽奖记录",
|
"noHistory": "暂无抽奖记录",
|
||||||
"banner": {
|
"banner": {
|
||||||
"title": "试试运气!",
|
"title": "试试运气!",
|
||||||
@@ -751,6 +754,7 @@
|
|||||||
"limitsAndRtp": "限制和RTP",
|
"limitsAndRtp": "限制和RTP",
|
||||||
"promocodes": "促销码"
|
"promocodes": "促销码"
|
||||||
},
|
},
|
||||||
|
"starsNotEnabledGlobally": "星星支付未在支付方式中启用。请在「支付方式」中启用「Telegram Stars」。",
|
||||||
"prizes": {
|
"prizes": {
|
||||||
"addPrize": "添加奖品",
|
"addPrize": "添加奖品",
|
||||||
"editPrize": "编辑奖品",
|
"editPrize": "编辑奖品",
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
mountClosingBehavior,
|
mountClosingBehavior,
|
||||||
disableClosingConfirmation,
|
disableClosingConfirmation,
|
||||||
mountBackButton,
|
mountBackButton,
|
||||||
mountMainButton,
|
|
||||||
bindThemeParamsCssVars,
|
bindThemeParamsCssVars,
|
||||||
bindViewportCssVars,
|
bindViewportCssVars,
|
||||||
requestFullscreen,
|
requestFullscreen,
|
||||||
@@ -66,12 +65,6 @@ if (!alreadyInitialized) {
|
|||||||
} catch {
|
} catch {
|
||||||
/* already mounted */
|
/* already mounted */
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
mountMainButton();
|
|
||||||
} catch {
|
|
||||||
/* already mounted */
|
|
||||||
}
|
|
||||||
|
|
||||||
// Viewport — async, fullscreen зависит от смонтированного viewport
|
// Viewport — async, fullscreen зависит от смонтированного viewport
|
||||||
mountViewport()
|
mountViewport()
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
|||||||
@@ -21,8 +21,11 @@ import {
|
|||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel';
|
import { adminWheelApi, type WheelPrizeAdmin, type CreateWheelPrizeData } from '../api/wheel';
|
||||||
|
import { adminPaymentMethodsApi } from '../api/adminPaymentMethods';
|
||||||
import { useDestructiveConfirm } from '@/platform';
|
import { useDestructiveConfirm } from '@/platform';
|
||||||
|
import { useNotify } from '@/platform/hooks/useNotify';
|
||||||
import FortuneWheel from '../components/wheel/FortuneWheel';
|
import FortuneWheel from '../components/wheel/FortuneWheel';
|
||||||
|
import { ColorPicker } from '@/components/ColorPicker';
|
||||||
import { useBackButton } from '../platform/hooks/useBackButton';
|
import { useBackButton } from '../platform/hooks/useBackButton';
|
||||||
import { usePlatform } from '../platform/hooks/usePlatform';
|
import { usePlatform } from '../platform/hooks/usePlatform';
|
||||||
|
|
||||||
@@ -290,6 +293,7 @@ export default function AdminWheel() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const confirmDelete = useDestructiveConfirm();
|
const confirmDelete = useDestructiveConfirm();
|
||||||
const { capabilities } = usePlatform();
|
const { capabilities } = usePlatform();
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
// Use native Telegram back button in Mini App
|
// Use native Telegram back button in Mini App
|
||||||
useBackButton(() => navigate('/admin'));
|
useBackButton(() => navigate('/admin'));
|
||||||
@@ -793,10 +797,21 @@ export default function AdminWheel() {
|
|||||||
{hasSettingsChanges && (
|
{hasSettingsChanges && (
|
||||||
<div className="flex justify-end border-t border-dark-700 pt-6">
|
<div className="flex justify-end border-t border-dark-700 pt-6">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={async () => {
|
||||||
if (settingsForm) {
|
if (!settingsForm) return;
|
||||||
updateConfigMutation.mutate(settingsForm);
|
if (settingsForm.spin_cost_stars_enabled) {
|
||||||
|
try {
|
||||||
|
const methods = await adminPaymentMethodsApi.getAll();
|
||||||
|
const starsMethod = methods.find((m) => m.method_id === 'telegram_stars');
|
||||||
|
if (!starsMethod?.is_enabled) {
|
||||||
|
notify.warning(t('admin.wheel.starsNotEnabledGlobally'));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// If we can't check, allow saving
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updateConfigMutation.mutate(settingsForm);
|
||||||
}}
|
}}
|
||||||
disabled={updateConfigMutation.isPending}
|
disabled={updateConfigMutation.isPending}
|
||||||
className="btn-primary flex items-center gap-2"
|
className="btn-primary flex items-center gap-2"
|
||||||
@@ -1146,26 +1161,11 @@ function InlinePrizeForm({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Color */}
|
{/* Color */}
|
||||||
<div>
|
<ColorPicker
|
||||||
<label className="mb-1 block text-sm font-medium text-dark-300">
|
label={t('admin.wheel.prizes.fields.color')}
|
||||||
{t('admin.wheel.prizes.fields.color')}
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="color"
|
|
||||||
value={formData.color}
|
value={formData.color}
|
||||||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
onChange={(color) => setFormData({ ...formData, color })}
|
||||||
className="h-10 w-12 cursor-pointer rounded"
|
|
||||||
/>
|
/>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.color}
|
|
||||||
onChange={(e) => setFormData({ ...formData, color: e.target.value })}
|
|
||||||
className="input flex-1"
|
|
||||||
pattern="^#[0-9A-Fa-f]{6}$"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active toggle */}
|
{/* Active toggle */}
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||||||
|
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
import { balanceApi } from '../api/balance';
|
import { balanceApi } from '../api/balance';
|
||||||
import TopUpModal from '../components/TopUpModal';
|
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import type { PaymentMethod, PaginatedResponse, Transaction } from '../types';
|
import type { PaginatedResponse, Transaction } from '../types';
|
||||||
|
|
||||||
import { Card } from '@/components/data-display/Card';
|
import { Card } from '@/components/data-display/Card';
|
||||||
import { Button } from '@/components/primitives/Button';
|
import { Button } from '@/components/primitives/Button';
|
||||||
@@ -92,7 +91,6 @@ export default function Balance() {
|
|||||||
}
|
}
|
||||||
}, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]);
|
}, [searchParams, navigate, refetchBalance, refreshUser, queryClient, showToast, t]);
|
||||||
|
|
||||||
const [selectedMethod, setSelectedMethod] = useState<PaymentMethod | null>(null);
|
|
||||||
const [promocode, setPromocode] = useState('');
|
const [promocode, setPromocode] = useState('');
|
||||||
const [promocodeLoading, setPromocodeLoading] = useState(false);
|
const [promocodeLoading, setPromocodeLoading] = useState(false);
|
||||||
const [promocodeError, setPromocodeError] = useState<string | null>(null);
|
const [promocodeError, setPromocodeError] = useState<string | null>(null);
|
||||||
@@ -285,7 +283,7 @@ export default function Balance() {
|
|||||||
key={method.id}
|
key={method.id}
|
||||||
interactive={method.is_available}
|
interactive={method.is_available}
|
||||||
className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''}
|
className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''}
|
||||||
onClick={() => method.is_available && setSelectedMethod(method)}
|
onClick={() => method.is_available && navigate(`/balance/top-up/${method.id}`)}
|
||||||
>
|
>
|
||||||
<div className="font-semibold text-dark-100">
|
<div className="font-semibold text-dark-100">
|
||||||
{translatedName || method.name}
|
{translatedName || method.name}
|
||||||
@@ -423,11 +421,6 @@ export default function Balance() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* TopUp Modal */}
|
|
||||||
{selectedMethod && (
|
|
||||||
<TopUpModal method={selectedMethod} onClose={() => setSelectedMethod(null)} />
|
|
||||||
)}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
import { balanceApi } from '../api/balance';
|
import { balanceApi } from '../api/balance';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
import { useTelegramWebApp } from '../hooks/useTelegramWebApp';
|
|
||||||
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
|
||||||
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
import { useCloseOnSuccessNotification } from '../store/successNotification';
|
||||||
import { useBackButton, useMainButton, useHaptic, usePlatform } from '@/platform';
|
import { useBackButton, useHaptic, usePlatform } from '@/platform';
|
||||||
|
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||||
import type { PaymentMethod } from '../types';
|
import type { PaymentMethod } from '../types';
|
||||||
import BentoCard from './ui/BentoCard';
|
import BentoCard from '../components/ui/BentoCard';
|
||||||
|
|
||||||
// Icons
|
// Icons
|
||||||
const CloseIcon = () => (
|
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const WalletIcon = () => (
|
|
||||||
<svg className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M21 12a2.25 2.25 0 00-2.25-2.25H15a3 3 0 11-6 0H5.25A2.25 2.25 0 003 12m18 0v6a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 18v-6m18 0V9M3 12V9m18 0a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 9m18 0V6a2.25 2.25 0 00-2.25-2.25H5.25A2.25 2.25 0 003 6v3"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const StarIcon = () => (
|
const StarIcon = () => (
|
||||||
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
<svg className="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
|
||||||
@@ -86,26 +72,6 @@ const CheckIcon = () => (
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
interface TopUpModalProps {
|
|
||||||
method: PaymentMethod;
|
|
||||||
onClose: () => void;
|
|
||||||
initialAmountRubles?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function useIsMobile() {
|
|
||||||
const [isMobile, setIsMobile] = useState(() => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return window.innerWidth < 640;
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
const check = () => setIsMobile(window.innerWidth < 640);
|
|
||||||
window.addEventListener('resize', check);
|
|
||||||
return () => window.removeEventListener('resize', check);
|
|
||||||
}, []);
|
|
||||||
return isMobile;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get method icon based on method type
|
|
||||||
const getMethodIcon = (methodId: string) => {
|
const getMethodIcon = (methodId: string) => {
|
||||||
const id = methodId.toLowerCase();
|
const id = methodId.toLowerCase();
|
||||||
if (id.includes('stars')) return <StarIcon />;
|
if (id.includes('stars')) return <StarIcon />;
|
||||||
@@ -113,19 +79,52 @@ const getMethodIcon = (methodId: string) => {
|
|||||||
return <CardIcon />;
|
return <CardIcon />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function TopUpModal({ method, onClose, initialAmountRubles }: TopUpModalProps) {
|
export default function TopUpAmount() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { methodId } = useParams<{ methodId: string }>();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
|
const { formatAmount, currencySymbol, convertAmount, convertToRub, targetCurrency } =
|
||||||
useCurrency();
|
useCurrency();
|
||||||
const { isTelegramWebApp, safeAreaInset, contentSafeAreaInset } = useTelegramWebApp();
|
|
||||||
const { openInvoice } = usePlatform();
|
const { openInvoice } = usePlatform();
|
||||||
const haptic = useHaptic();
|
const haptic = useHaptic();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const isMobileScreen = useIsMobile();
|
|
||||||
|
|
||||||
const safeBottom = isTelegramWebApp
|
const returnTo = searchParams.get('returnTo');
|
||||||
? Math.max(safeAreaInset.bottom, contentSafeAreaInset.bottom)
|
const initialAmountRubles = searchParams.get('amount')
|
||||||
: 0;
|
? parseFloat(searchParams.get('amount')!)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Get method from cached payment-methods query
|
||||||
|
const cachedMethods = queryClient.getQueryData<PaymentMethod[]>(['payment-methods']);
|
||||||
|
const method = cachedMethods?.find((m) => m.id === methodId);
|
||||||
|
|
||||||
|
const handleNavigateBack = useCallback(() => {
|
||||||
|
navigate(-1);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleSuccess = useCallback(() => {
|
||||||
|
navigate(returnTo || '/balance', { replace: true });
|
||||||
|
}, [navigate, returnTo]);
|
||||||
|
|
||||||
|
// Telegram back button
|
||||||
|
useBackButton(handleNavigateBack);
|
||||||
|
|
||||||
|
// Keyboard: Escape to go back
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleNavigateBack();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [handleNavigateBack]);
|
||||||
|
|
||||||
|
// Auto-redirect when success notification appears (e.g., balance topped up via WebSocket)
|
||||||
|
useCloseOnSuccessNotification(handleSuccess);
|
||||||
|
|
||||||
const getInitialAmount = (): string => {
|
const getInitialAmount = (): string => {
|
||||||
if (!initialAmountRubles || initialAmountRubles <= 0) return '';
|
if (!initialAmountRubles || initialAmountRubles <= 0) return '';
|
||||||
@@ -138,65 +137,24 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
const [amount, setAmount] = useState(getInitialAmount);
|
const [amount, setAmount] = useState(getInitialAmount);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [selectedOption, setSelectedOption] = useState<string | null>(
|
const [selectedOption, setSelectedOption] = useState<string | null>(
|
||||||
method.options && method.options.length > 0 ? method.options[0].id : null,
|
method?.options && method.options.length > 0 ? method.options[0].id : null,
|
||||||
);
|
);
|
||||||
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
|
const [paymentUrl, setPaymentUrl] = useState<string | null>(null);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
// If method not found in cache, redirect to method selection
|
||||||
onClose();
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
// Auto-close when success notification appears (e.g., balance topped up via WebSocket)
|
|
||||||
useCloseOnSuccessNotification(handleClose);
|
|
||||||
|
|
||||||
// Keyboard: Escape to close (PC)
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
if (cachedMethods && !method) {
|
||||||
if (e.key === 'Escape') {
|
const params = new URLSearchParams();
|
||||||
e.preventDefault();
|
const amount = searchParams.get('amount');
|
||||||
handleClose();
|
const rt = searchParams.get('returnTo');
|
||||||
|
if (amount) params.set('amount', amount);
|
||||||
|
if (rt) params.set('returnTo', rt);
|
||||||
|
const qs = params.toString();
|
||||||
|
navigate(`/balance/top-up${qs ? `?${qs}` : ''}`, { replace: true });
|
||||||
}
|
}
|
||||||
};
|
}, [cachedMethods, method, navigate, searchParams]);
|
||||||
document.addEventListener('keydown', handleKeyDown);
|
|
||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
|
||||||
}, [handleClose]);
|
|
||||||
|
|
||||||
// Telegram back button - using platform hook
|
|
||||||
useBackButton(handleClose);
|
|
||||||
|
|
||||||
// Scroll lock
|
|
||||||
useEffect(() => {
|
|
||||||
const scrollY = window.scrollY;
|
|
||||||
const preventScroll = (e: TouchEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-modal-content]')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
const preventWheel = (e: WheelEvent) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
if (target.closest('[data-modal-content]')) return;
|
|
||||||
e.preventDefault();
|
|
||||||
};
|
|
||||||
document.addEventListener('touchmove', preventScroll, { passive: false });
|
|
||||||
document.addEventListener('wheel', preventWheel, { passive: false });
|
|
||||||
document.body.style.overflow = 'hidden';
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('touchmove', preventScroll);
|
|
||||||
document.removeEventListener('wheel', preventWheel);
|
|
||||||
document.body.style.overflow = '';
|
|
||||||
window.scrollTo(0, scrollY);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const hasOptions = method.options && method.options.length > 0;
|
|
||||||
const minRubles = method.min_amount_kopeks / 100;
|
|
||||||
const maxRubles = method.max_amount_kopeks / 100;
|
|
||||||
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
|
||||||
const isStarsMethod = methodKey.includes('stars');
|
|
||||||
const methodName =
|
|
||||||
t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name;
|
|
||||||
|
|
||||||
const starsPaymentMutation = useMutation({
|
const starsPaymentMutation = useMutation({
|
||||||
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
mutationFn: (amountKopeks: number) => balanceApi.createStarsInvoice(amountKopeks),
|
||||||
@@ -206,17 +164,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
// Use platform-agnostic invoice opening
|
|
||||||
const status = await openInvoice(data.invoice_url);
|
const status = await openInvoice(data.invoice_url);
|
||||||
if (status === 'paid') {
|
if (status === 'paid') {
|
||||||
haptic.notification('success');
|
haptic.notification('success');
|
||||||
setError(null);
|
setError(null);
|
||||||
onClose();
|
handleSuccess();
|
||||||
} else if (status === 'failed') {
|
} else if (status === 'failed') {
|
||||||
haptic.notification('error');
|
haptic.notification('error');
|
||||||
setError(t('wheel.starsPaymentFailed'));
|
setError(t('wheel.starsPaymentFailed'));
|
||||||
}
|
}
|
||||||
// 'pending' and 'cancelled' just close without action
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(t('balance.errors.generic', { details: String(e) }));
|
setError(t('balance.errors.generic', { details: String(e) }));
|
||||||
}
|
}
|
||||||
@@ -241,13 +197,13 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
unknown,
|
unknown,
|
||||||
number
|
number
|
||||||
>({
|
>({
|
||||||
mutationFn: (amountKopeks: number) =>
|
mutationFn: (amountKopeks: number) => {
|
||||||
balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined),
|
if (!method) throw new Error('Method not loaded');
|
||||||
|
return balanceApi.createTopUp(amountKopeks, method.id, selectedOption || undefined);
|
||||||
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
const redirectUrl = data.payment_url || data.invoice_url;
|
const redirectUrl = data.payment_url || data.invoice_url;
|
||||||
if (redirectUrl) {
|
if (redirectUrl) {
|
||||||
// Always show the payment link for user to click manually
|
|
||||||
// This ensures it works on all platforms including iOS Safari
|
|
||||||
setPaymentUrl(redirectUrl);
|
setPaymentUrl(redirectUrl);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -260,6 +216,32 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-focus input
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!method) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasOptions = method.options && method.options.length > 0;
|
||||||
|
const minRubles = method.min_amount_kopeks / 100;
|
||||||
|
const maxRubles = method.max_amount_kopeks / 100;
|
||||||
|
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||||
|
const isStarsMethod = methodKey.includes('stars');
|
||||||
|
const methodName =
|
||||||
|
t(`balance.paymentMethods.${methodKey}.name`, { defaultValue: '' }) || method.name;
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleSubmit = () => {
|
||||||
setError(null);
|
setError(null);
|
||||||
setPaymentUrl(null);
|
setPaymentUrl(null);
|
||||||
@@ -302,24 +284,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
: convertAmount(rub).toFixed(currencyDecimals);
|
: convertAmount(rub).toFixed(currencyDecimals);
|
||||||
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending;
|
const isPending = topUpMutation.isPending || starsPaymentMutation.isPending;
|
||||||
|
|
||||||
// Check if form is valid for MainButton
|
|
||||||
const amountNum = parseFloat(amount);
|
|
||||||
const amountRubles = !isNaN(amountNum) && amountNum > 0 ? convertToRub(amountNum) : 0;
|
|
||||||
const isFormValid =
|
|
||||||
!isPending &&
|
|
||||||
!paymentUrl &&
|
|
||||||
amountRubles >= minRubles &&
|
|
||||||
amountRubles <= maxRubles &&
|
|
||||||
(!hasOptions || !!selectedOption);
|
|
||||||
|
|
||||||
// Telegram MainButton integration - shows "Top Up" action
|
|
||||||
useMainButton(isFormValid && !isInputFocused ? handleSubmit : null, {
|
|
||||||
text: t('balance.topUp'),
|
|
||||||
isLoading: isPending,
|
|
||||||
isActive: isFormValid,
|
|
||||||
visible: !paymentUrl && isMobileScreen,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleCopyUrl = async () => {
|
const handleCopyUrl = async () => {
|
||||||
if (!paymentUrl) return;
|
if (!paymentUrl) return;
|
||||||
try {
|
try {
|
||||||
@@ -331,25 +295,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-focus input - works on mobile in Telegram WebApp
|
return (
|
||||||
useEffect(() => {
|
<motion.div
|
||||||
const timer = setTimeout(() => {
|
className="mx-auto max-w-lg space-y-5"
|
||||||
if (inputRef.current) {
|
variants={staggerContainer}
|
||||||
inputRef.current.focus();
|
initial="initial"
|
||||||
if (isMobileScreen) {
|
animate="animate"
|
||||||
inputRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
>
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Content JSX - shared between mobile and desktop
|
|
||||||
const contentJSX = (
|
|
||||||
<div className="space-y-5">
|
|
||||||
{/* Header icon and method */}
|
{/* Header icon and method */}
|
||||||
<div className="flex items-center gap-4 pb-1">
|
<motion.div variants={staggerItem} className="flex items-center gap-4 pb-1">
|
||||||
<div
|
<div
|
||||||
className={`flex h-14 w-14 items-center justify-center rounded-2xl ${
|
className={`flex h-14 w-14 items-center justify-center rounded-2xl ${
|
||||||
isStarsMethod
|
isStarsMethod
|
||||||
@@ -365,11 +319,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
{formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)} {currencySymbol}
|
{formatAmount(minRubles, 0)} – {formatAmount(maxRubles, 0)} {currencySymbol}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Payment options (if any) */}
|
{/* Payment options (if any) */}
|
||||||
{hasOptions && method.options && (
|
{hasOptions && method.options && (
|
||||||
<div className="space-y-2">
|
<motion.div variants={staggerItem} className="space-y-2">
|
||||||
<label className="text-sm font-medium text-dark-400">{t('balance.paymentMethod')}</label>
|
<label className="text-sm font-medium text-dark-400">{t('balance.paymentMethod')}</label>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{method.options.map((opt) => (
|
{method.options.map((opt) => (
|
||||||
@@ -392,11 +346,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Amount input + Submit button - inline */}
|
{/* Amount input + Submit button - inline */}
|
||||||
<div className="space-y-2">
|
<motion.div variants={staggerItem} className="space-y-2">
|
||||||
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
|
<label className="text-sm font-medium text-dark-400">{t('balance.enterAmount')}</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div
|
<div
|
||||||
@@ -452,11 +406,11 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
{/* Quick amount buttons */}
|
{/* Quick amount buttons */}
|
||||||
{quickAmounts.length > 0 && (
|
{quickAmounts.length > 0 && (
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<motion.div variants={staggerItem} className="grid grid-cols-4 gap-2">
|
||||||
{quickAmounts.map((a) => {
|
{quickAmounts.map((a) => {
|
||||||
const val = getQuickValue(a);
|
const val = getQuickValue(a);
|
||||||
const isSelected = amount === val;
|
const isSelected = amount === val;
|
||||||
@@ -488,12 +442,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
</BentoCard>
|
</BentoCard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error message */}
|
{/* Error message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3">
|
<motion.div
|
||||||
|
variants={staggerItem}
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-error-500/20 bg-error-500/10 p-3"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
className="h-5 w-5 shrink-0 text-error-400"
|
className="h-5 w-5 shrink-0 text-error-400"
|
||||||
fill="none"
|
fill="none"
|
||||||
@@ -508,12 +465,15 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm text-error-400">{error}</span>
|
<span className="text-sm text-error-400">{error}</span>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Payment link display - shown when URL is received */}
|
{/* Payment link display - shown when URL is received */}
|
||||||
{paymentUrl && (
|
{paymentUrl && (
|
||||||
<div className="space-y-3 rounded-2xl border border-success-500/20 bg-success-500/10 p-4">
|
<motion.div
|
||||||
|
variants={staggerItem}
|
||||||
|
className="space-y-3 rounded-2xl border border-success-500/20 bg-success-500/10 p-4"
|
||||||
|
>
|
||||||
<div className="flex items-center gap-2 text-success-400">
|
<div className="flex items-center gap-2 text-success-400">
|
||||||
<CheckIcon />
|
<CheckIcon />
|
||||||
<span className="font-semibold">{t('balance.paymentReady')}</span>
|
<span className="font-semibold">{t('balance.paymentReady')}</span>
|
||||||
@@ -521,7 +481,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
|
|
||||||
<p className="text-sm text-dark-400">{t('balance.clickToOpenPayment')}</p>
|
<p className="text-sm text-dark-400">{t('balance.clickToOpenPayment')}</p>
|
||||||
|
|
||||||
{/* Main open button - NO preventDefault, let <a> work natively for iOS Safari */}
|
|
||||||
<a
|
<a
|
||||||
href={paymentUrl}
|
href={paymentUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -532,7 +491,6 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
<span>{t('balance.openPaymentPage')}</span>
|
<span>{t('balance.openPaymentPage')}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
{/* Copy and link display */}
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="min-w-0 flex-1 rounded-lg border border-dark-700/50 bg-dark-800/70 px-3 py-2">
|
<div className="min-w-0 flex-1 rounded-lg border border-dark-700/50 bg-dark-800/70 px-3 py-2">
|
||||||
<p className="truncate text-xs text-dark-500">{paymentUrl}</p>
|
<p className="truncate text-xs text-dark-500">{paymentUrl}</p>
|
||||||
@@ -550,87 +508,8 @@ export default function TopUpModal({ method, onClose, initialAmountRubles }: Top
|
|||||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|
||||||
// Render modal based on screen size - NO nested components!
|
|
||||||
const modalContent = isMobileScreen ? (
|
|
||||||
<>
|
|
||||||
{/* Backdrop */}
|
|
||||||
<div className="fixed inset-0 z-[9998] bg-black/70" onClick={handleClose} />
|
|
||||||
{/* Bottom sheet */}
|
|
||||||
<div
|
|
||||||
data-modal-content
|
|
||||||
className="fixed inset-x-0 bottom-0 z-[9999] flex max-h-[90vh] flex-col overflow-hidden rounded-t-3xl bg-dark-900"
|
|
||||||
style={{
|
|
||||||
paddingBottom: safeBottom
|
|
||||||
? `${safeBottom + 20}px`
|
|
||||||
: 'max(20px, env(safe-area-inset-bottom))',
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Handle bar */}
|
|
||||||
<div className="flex justify-center pb-1 pt-3">
|
|
||||||
<div className="h-1 w-10 rounded-full bg-dark-600" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between px-5 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<WalletIcon />
|
|
||||||
<span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="-mr-2 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-800"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Divider */}
|
|
||||||
<div className="mx-5 h-px bg-gradient-to-r from-transparent via-dark-700 to-transparent" />
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="overflow-y-auto px-5 py-5">{contentJSX}</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-[60] flex items-start justify-center p-4 pt-[10vh]"
|
|
||||||
onClick={handleClose}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-modal-content
|
|
||||||
className="w-full max-w-md overflow-hidden rounded-3xl border border-dark-700/50 bg-dark-900 shadow-2xl"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-center justify-between border-b border-dark-700/50 bg-gradient-to-r from-dark-800/80 to-dark-800/40 px-6 py-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-accent-500/10 text-accent-400">
|
|
||||||
<WalletIcon />
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold text-dark-100">{t('balance.topUp')}</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleClose}
|
|
||||||
className="-mr-1 rounded-xl p-2 text-dark-400 transition-colors hover:bg-dark-700"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="p-6">{contentJSX}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
return createPortal(modalContent, document.body);
|
|
||||||
}
|
|
||||||
return modalContent;
|
|
||||||
}
|
}
|
||||||
97
src/pages/TopUpMethodSelect.tsx
Normal file
97
src/pages/TopUpMethodSelect.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
|
||||||
|
import { balanceApi } from '../api/balance';
|
||||||
|
import { useCurrency } from '../hooks/useCurrency';
|
||||||
|
import { useBackButton } from '@/platform';
|
||||||
|
import { Card } from '@/components/data-display/Card';
|
||||||
|
import { staggerContainer, staggerItem } from '@/components/motion/transitions';
|
||||||
|
|
||||||
|
export default function TopUpMethodSelect() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { formatAmount, currencySymbol } = useCurrency();
|
||||||
|
|
||||||
|
useBackButton(() => navigate('/balance'));
|
||||||
|
|
||||||
|
const { data: paymentMethods, isLoading } = useQuery({
|
||||||
|
queryKey: ['payment-methods'],
|
||||||
|
queryFn: balanceApi.getPaymentMethods,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMethodClick = (methodId: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
const amount = searchParams.get('amount');
|
||||||
|
const returnTo = searchParams.get('returnTo');
|
||||||
|
if (amount) params.set('amount', amount);
|
||||||
|
if (returnTo) params.set('returnTo', returnTo);
|
||||||
|
const qs = params.toString();
|
||||||
|
navigate(`/balance/top-up/${methodId}${qs ? `?${qs}` : ''}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
className="space-y-6"
|
||||||
|
variants={staggerContainer}
|
||||||
|
initial="initial"
|
||||||
|
animate="animate"
|
||||||
|
>
|
||||||
|
<motion.div variants={staggerItem}>
|
||||||
|
<h1 className="text-2xl font-bold text-dark-50 sm:text-3xl">
|
||||||
|
{t('balance.selectPaymentMethod')}
|
||||||
|
</h1>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
<motion.div variants={staggerItem}>
|
||||||
|
<Card>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-2 border-accent-500 border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
) : !paymentMethods || paymentMethods.length === 0 ? (
|
||||||
|
<div className="py-6 text-center text-sm text-dark-400">
|
||||||
|
{t('balance.noPaymentMethods')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{paymentMethods.map((method) => {
|
||||||
|
const methodKey = method.id.toLowerCase().replace(/-/g, '_');
|
||||||
|
const translatedName = t(`balance.paymentMethods.${methodKey}.name`, {
|
||||||
|
defaultValue: '',
|
||||||
|
});
|
||||||
|
const translatedDesc = t(`balance.paymentMethods.${methodKey}.description`, {
|
||||||
|
defaultValue: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={method.id}
|
||||||
|
interactive={method.is_available}
|
||||||
|
className={!method.is_available ? 'cursor-not-allowed opacity-50' : ''}
|
||||||
|
onClick={() => method.is_available && handleMethodClick(method.id)}
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-dark-100">
|
||||||
|
{translatedName || method.name}
|
||||||
|
</div>
|
||||||
|
{(translatedDesc || method.description) && (
|
||||||
|
<div className="mt-1 text-sm text-dark-500">
|
||||||
|
{translatedDesc || method.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mt-3 text-xs text-dark-600">
|
||||||
|
{formatAmount(method.min_amount_kopeks / 100, 0)} –{' '}
|
||||||
|
{formatAmount(method.max_amount_kopeks / 100, 0)} {currencySymbol}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ import { wheelApi, type SpinResult, type SpinHistoryItem } from '../api/wheel';
|
|||||||
import FortuneWheel from '../components/wheel/FortuneWheel';
|
import FortuneWheel from '../components/wheel/FortuneWheel';
|
||||||
import WheelLegend from '../components/wheel/WheelLegend';
|
import WheelLegend from '../components/wheel/WheelLegend';
|
||||||
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
import InsufficientBalancePrompt from '../components/InsufficientBalancePrompt';
|
||||||
import { useCurrency } from '../hooks/useCurrency';
|
|
||||||
import { usePlatform, useHaptic } from '@/platform';
|
import { usePlatform, useHaptic } from '@/platform';
|
||||||
|
import { useNotify } from '@/platform/hooks/useNotify';
|
||||||
import { Card } from '@/components/data-display/Card/Card';
|
import { Card } from '@/components/data-display/Card/Card';
|
||||||
import { Button } from '@/components/primitives/Button/Button';
|
import { Button } from '@/components/primitives/Button/Button';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
@@ -60,9 +60,9 @@ const ChevronIcon = ({ expanded }: { expanded: boolean }) => (
|
|||||||
export default function Wheel() {
|
export default function Wheel() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { formatAmount, currencySymbol } = useCurrency();
|
const { openInvoice, capabilities } = usePlatform();
|
||||||
const { platform, openInvoice, capabilities } = usePlatform();
|
|
||||||
const haptic = useHaptic();
|
const haptic = useHaptic();
|
||||||
|
const notify = useNotify();
|
||||||
|
|
||||||
const [isSpinning, setIsSpinning] = useState(false);
|
const [isSpinning, setIsSpinning] = useState(false);
|
||||||
const [targetRotation, setTargetRotation] = useState<number | null>(null);
|
const [targetRotation, setTargetRotation] = useState<number | null>(null);
|
||||||
@@ -72,9 +72,8 @@ export default function Wheel() {
|
|||||||
);
|
);
|
||||||
const [isPayingStars, setIsPayingStars] = useState(false);
|
const [isPayingStars, setIsPayingStars] = useState(false);
|
||||||
const [historyExpanded, setHistoryExpanded] = useState(false);
|
const [historyExpanded, setHistoryExpanded] = useState(false);
|
||||||
|
const [showStarsConfirm, setShowStarsConfirm] = useState(false);
|
||||||
// Check if we're in Telegram Mini App environment
|
const paymentTypeInitialized = useRef(false);
|
||||||
const isTelegramMiniApp = platform === 'telegram';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: config,
|
data: config,
|
||||||
@@ -90,31 +89,20 @@ export default function Wheel() {
|
|||||||
queryFn: () => wheelApi.getHistory(1, 10),
|
queryFn: () => wheelApi.getHistory(1, 10),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-select payment type based on availability
|
// Auto-select payment type based on availability (only on initial load)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!config) return;
|
if (!config || paymentTypeInitialized.current) return;
|
||||||
|
paymentTypeInitialized.current = true;
|
||||||
|
|
||||||
const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars;
|
const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars;
|
||||||
const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days;
|
const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days;
|
||||||
const canPayBalance = starsEnabled && config.can_pay_stars;
|
|
||||||
const canPayDays = daysEnabled && config.can_pay_days;
|
|
||||||
|
|
||||||
if (isTelegramMiniApp) {
|
|
||||||
// In Mini App: prefer stars if available
|
|
||||||
if (starsEnabled) {
|
if (starsEnabled) {
|
||||||
setPaymentType('telegram_stars');
|
setPaymentType('telegram_stars');
|
||||||
} else if (canPayDays) {
|
} else if (daysEnabled) {
|
||||||
setPaymentType('subscription_days');
|
setPaymentType('subscription_days');
|
||||||
}
|
}
|
||||||
} else {
|
}, [config]);
|
||||||
// In Web: prefer balance (Stars converted to rubles), fallback to days
|
|
||||||
if (canPayBalance) {
|
|
||||||
setPaymentType('telegram_stars');
|
|
||||||
} else if (canPayDays) {
|
|
||||||
setPaymentType('subscription_days');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [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(
|
const pollForSpinResult = useCallback(
|
||||||
@@ -182,6 +170,7 @@ export default function Wheel() {
|
|||||||
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);
|
const pollingAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const preOpenedWindowRef = useRef<Window | null>(null);
|
||||||
|
|
||||||
// Cleanup polling on unmount
|
// Cleanup polling on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -283,9 +272,12 @@ export default function Wheel() {
|
|||||||
setIsPayingStars(false);
|
setIsPayingStars(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: open invoice URL in browser (unsupported platform)
|
// Fallback: redirect pre-opened window to invoice URL
|
||||||
setIsPayingStars(false);
|
setIsPayingStars(false);
|
||||||
window.open(data.invoice_url, '_blank', 'noopener,noreferrer');
|
if (preOpenedWindowRef.current) {
|
||||||
|
preOpenedWindowRef.current.location.href = data.invoice_url;
|
||||||
|
preOpenedWindowRef.current = null;
|
||||||
|
}
|
||||||
setSpinResult({
|
setSpinResult({
|
||||||
success: true,
|
success: true,
|
||||||
prize_id: null,
|
prize_id: null,
|
||||||
@@ -303,6 +295,10 @@ export default function Wheel() {
|
|||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
setIsPayingStars(false);
|
setIsPayingStars(false);
|
||||||
|
if (preOpenedWindowRef.current) {
|
||||||
|
preOpenedWindowRef.current.close();
|
||||||
|
preOpenedWindowRef.current = null;
|
||||||
|
}
|
||||||
setSpinResult({
|
setSpinResult({
|
||||||
success: false,
|
success: false,
|
||||||
prize_id: null,
|
prize_id: null,
|
||||||
@@ -320,7 +316,12 @@ export default function Wheel() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const handleDirectStarsPay = () => {
|
const handleDirectStarsPay = () => {
|
||||||
|
setShowStarsConfirm(false);
|
||||||
setIsPayingStars(true);
|
setIsPayingStars(true);
|
||||||
|
// In browser: pre-open window synchronously (direct user gesture) to avoid popup blocker
|
||||||
|
if (!capabilities.hasInvoice) {
|
||||||
|
preOpenedWindowRef.current = window.open('about:blank', '_blank') || null;
|
||||||
|
}
|
||||||
starsInvoiceMutation.mutate();
|
starsInvoiceMutation.mutate();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -360,8 +361,12 @@ export default function Wheel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleUnifiedSpin = () => {
|
const handleUnifiedSpin = () => {
|
||||||
if (isTelegramMiniApp && paymentType === 'telegram_stars') {
|
if (paymentType === 'telegram_stars') {
|
||||||
handleDirectStarsPay();
|
if (!config?.spin_cost_stars_enabled || !config?.spin_cost_stars) {
|
||||||
|
notify.warning(t('wheel.starsNotAvailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setShowStarsConfirm(true);
|
||||||
} else {
|
} else {
|
||||||
handleSpin();
|
handleSpin();
|
||||||
}
|
}
|
||||||
@@ -454,11 +459,7 @@ export default function Wheel() {
|
|||||||
|
|
||||||
const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars;
|
const starsEnabled = config.spin_cost_stars_enabled && config.spin_cost_stars;
|
||||||
const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days;
|
const daysEnabled = config.spin_cost_days_enabled && config.spin_cost_days;
|
||||||
const canPayBalance = starsEnabled && config.can_pay_stars; // For web: pay with internal balance
|
const bothMethodsAvailable = !!(starsEnabled && daysEnabled);
|
||||||
const canPayDays = daysEnabled && config.can_pay_days;
|
|
||||||
const bothMethodsAvailable = isTelegramMiniApp
|
|
||||||
? starsEnabled && daysEnabled && canPayDays
|
|
||||||
: starsEnabled && daysEnabled && canPayBalance && canPayDays;
|
|
||||||
|
|
||||||
const spinDisabled =
|
const spinDisabled =
|
||||||
!config.can_spin ||
|
!config.can_spin ||
|
||||||
@@ -466,23 +467,6 @@ export default function Wheel() {
|
|||||||
isPayingStars ||
|
isPayingStars ||
|
||||||
(config.daily_limit > 0 && config.user_spins_today >= config.daily_limit);
|
(config.daily_limit > 0 && config.user_spins_today >= config.daily_limit);
|
||||||
|
|
||||||
// Build cost subtitle for the spin button
|
|
||||||
const getCostSubtitle = () => {
|
|
||||||
if (bothMethodsAvailable) return null; // toggle handles it
|
|
||||||
if (paymentType === 'telegram_stars' && starsEnabled) {
|
|
||||||
if (isTelegramMiniApp) {
|
|
||||||
return `${config.spin_cost_stars} ⭐`;
|
|
||||||
}
|
|
||||||
return `${formatAmount(config.required_balance_kopeks / 100)} ${currencySymbol} (${config.spin_cost_stars} ⭐)`;
|
|
||||||
}
|
|
||||||
if (paymentType === 'subscription_days' && daysEnabled) {
|
|
||||||
return t('wheel.days', { count: config.spin_cost_days ?? 0 });
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const costSubtitle = getCostSubtitle();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="animate-fade-in space-y-6 pb-8">
|
<div className="animate-fade-in space-y-6 pb-8">
|
||||||
{/* Simple Header */}
|
{/* Simple Header */}
|
||||||
@@ -513,10 +497,13 @@ export default function Wheel() {
|
|||||||
|
|
||||||
{/* Spin Controls */}
|
{/* Spin Controls */}
|
||||||
<div className="mt-8 space-y-4">
|
<div className="mt-8 space-y-4">
|
||||||
{/* Payment type toggle - only if both methods available */}
|
{/* Payment type selector */}
|
||||||
{bothMethodsAvailable && (
|
{(starsEnabled || daysEnabled) && (
|
||||||
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-1">
|
<div className="rounded-xl border border-dark-700/30 bg-dark-800/30 p-1">
|
||||||
<div className="grid grid-cols-2 gap-1">
|
<div
|
||||||
|
className={`grid gap-1 ${bothMethodsAvailable ? 'grid-cols-2' : 'grid-cols-1'}`}
|
||||||
|
>
|
||||||
|
{starsEnabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setPaymentType('telegram_stars')}
|
onClick={() => setPaymentType('telegram_stars')}
|
||||||
disabled={isSpinning}
|
disabled={isSpinning}
|
||||||
@@ -527,10 +514,10 @@ export default function Wheel() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<StarIcon />
|
<StarIcon />
|
||||||
{isTelegramMiniApp
|
{`${config.spin_cost_stars} ⭐`}
|
||||||
? `${config.spin_cost_stars} ⭐`
|
|
||||||
: `${formatAmount(config.required_balance_kopeks / 100)} ${currencySymbol}`}
|
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{daysEnabled && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setPaymentType('subscription_days')}
|
onClick={() => setPaymentType('subscription_days')}
|
||||||
disabled={isSpinning}
|
disabled={isSpinning}
|
||||||
@@ -543,11 +530,34 @@ export default function Wheel() {
|
|||||||
<CalendarIcon />
|
<CalendarIcon />
|
||||||
{t('wheel.days', { count: config.spin_cost_days ?? 0 })}
|
{t('wheel.days', { count: config.spin_cost_days ?? 0 })}
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Single Spin Button */}
|
{/* Stars confirmation panel */}
|
||||||
|
{showStarsConfirm && !isSpinning && !isPayingStars ? (
|
||||||
|
<div className="space-y-3 rounded-xl border border-accent-500/30 bg-accent-500/5 p-4">
|
||||||
|
<p className="text-center text-sm text-dark-300">
|
||||||
|
{t('wheel.confirmStarsPayment')}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowStarsConfirm(false)}
|
||||||
|
className="rounded-lg border border-dark-700 bg-dark-800 px-4 py-2.5 text-sm font-medium text-dark-300 transition-colors hover:bg-dark-700"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDirectStarsPay}
|
||||||
|
className="rounded-lg bg-accent-500 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-accent-600"
|
||||||
|
>
|
||||||
|
{t('wheel.payStars', { count: config.spin_cost_stars ?? 0 })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Single Spin Button */
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -558,10 +568,6 @@ export default function Wheel() {
|
|||||||
>
|
>
|
||||||
{isSpinning ? t('wheel.spinning') : t('wheel.spin')}
|
{isSpinning ? t('wheel.spinning') : t('wheel.spin')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* Cost subtitle when no toggle */}
|
|
||||||
{costSubtitle && !bothMethodsAvailable && (
|
|
||||||
<p className="text-center text-sm text-dark-400">{costSubtitle}</p>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Cannot spin hint */}
|
{/* Cannot spin hint */}
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ import {
|
|||||||
onBackButtonClick,
|
onBackButtonClick,
|
||||||
offBackButtonClick,
|
offBackButtonClick,
|
||||||
isBackButtonVisible,
|
isBackButtonVisible,
|
||||||
setMainButtonParams,
|
|
||||||
onMainButtonClick,
|
|
||||||
offMainButtonClick,
|
|
||||||
hapticFeedbackImpactOccurred,
|
hapticFeedbackImpactOccurred,
|
||||||
hapticFeedbackNotificationOccurred,
|
hapticFeedbackNotificationOccurred,
|
||||||
hapticFeedbackSelectionChanged,
|
hapticFeedbackSelectionChanged,
|
||||||
@@ -30,12 +27,10 @@ import type {
|
|||||||
PlatformContext,
|
PlatformContext,
|
||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
BackButtonController,
|
BackButtonController,
|
||||||
MainButtonController,
|
|
||||||
HapticController,
|
HapticController,
|
||||||
DialogController,
|
DialogController,
|
||||||
ThemeController,
|
ThemeController,
|
||||||
CloudStorageController,
|
CloudStorageController,
|
||||||
MainButtonConfig,
|
|
||||||
PopupOptions,
|
PopupOptions,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
HapticImpactStyle,
|
HapticImpactStyle,
|
||||||
@@ -47,7 +42,7 @@ function createCapabilities(): PlatformCapabilities {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
hasBackButton: inTelegram,
|
hasBackButton: inTelegram,
|
||||||
hasMainButton: inTelegram,
|
|
||||||
hasHapticFeedback: inTelegram,
|
hasHapticFeedback: inTelegram,
|
||||||
hasNativeDialogs: inTelegram,
|
hasNativeDialogs: inTelegram,
|
||||||
hasThemeSync: inTelegram,
|
hasThemeSync: inTelegram,
|
||||||
@@ -112,91 +107,6 @@ function createBackButtonController(): BackButtonController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMainButtonController(): MainButtonController {
|
|
||||||
const inTelegram = isInTelegramWebApp();
|
|
||||||
let currentCallback: (() => void) | null = null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
get isVisible() {
|
|
||||||
return false; // SDK v3 doesn't expose this as a simple getter
|
|
||||||
},
|
|
||||||
|
|
||||||
show(config: MainButtonConfig) {
|
|
||||||
if (!inTelegram) return;
|
|
||||||
|
|
||||||
if (currentCallback) {
|
|
||||||
try {
|
|
||||||
offMainButtonClick(currentCallback);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCallback = config.onClick;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setMainButtonParams({
|
|
||||||
text: config.text,
|
|
||||||
isVisible: true,
|
|
||||||
isEnabled: config.isActive !== false,
|
|
||||||
isLoaderVisible: config.isLoading ?? false,
|
|
||||||
backgroundColor: config.color as `#${string}` | undefined,
|
|
||||||
textColor: config.textColor as `#${string}` | undefined,
|
|
||||||
});
|
|
||||||
onMainButtonClick(config.onClick);
|
|
||||||
} catch {
|
|
||||||
// Main button not mounted
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
hide() {
|
|
||||||
if (!inTelegram) return;
|
|
||||||
|
|
||||||
if (currentCallback) {
|
|
||||||
try {
|
|
||||||
offMainButtonClick(currentCallback);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
currentCallback = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
setMainButtonParams({ isVisible: false, isLoaderVisible: false });
|
|
||||||
} catch {
|
|
||||||
// Main button not mounted
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
showProgress(show: boolean) {
|
|
||||||
if (!inTelegram) return;
|
|
||||||
try {
|
|
||||||
setMainButtonParams({ isLoaderVisible: show });
|
|
||||||
} catch {
|
|
||||||
// Main button not mounted
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setText(text: string) {
|
|
||||||
if (!inTelegram) return;
|
|
||||||
try {
|
|
||||||
setMainButtonParams({ text });
|
|
||||||
} catch {
|
|
||||||
// Main button not mounted
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
setActive(active: boolean) {
|
|
||||||
if (!inTelegram) return;
|
|
||||||
try {
|
|
||||||
setMainButtonParams({ isEnabled: active });
|
|
||||||
} catch {
|
|
||||||
// Main button not mounted
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHapticController(): HapticController {
|
function createHapticController(): HapticController {
|
||||||
const inTelegram = isInTelegramWebApp();
|
const inTelegram = isInTelegramWebApp();
|
||||||
|
|
||||||
@@ -382,7 +292,7 @@ export function createTelegramAdapter(): PlatformContext {
|
|||||||
platform: 'telegram',
|
platform: 'telegram',
|
||||||
capabilities: createCapabilities(),
|
capabilities: createCapabilities(),
|
||||||
backButton: createBackButtonController(),
|
backButton: createBackButtonController(),
|
||||||
mainButton: createMainButtonController(),
|
|
||||||
haptic: createHapticController(),
|
haptic: createHapticController(),
|
||||||
dialog: createDialogController(),
|
dialog: createDialogController(),
|
||||||
theme: createThemeController(),
|
theme: createThemeController(),
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ import type {
|
|||||||
PlatformContext,
|
PlatformContext,
|
||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
BackButtonController,
|
BackButtonController,
|
||||||
MainButtonController,
|
|
||||||
HapticController,
|
HapticController,
|
||||||
DialogController,
|
DialogController,
|
||||||
ThemeController,
|
ThemeController,
|
||||||
CloudStorageController,
|
CloudStorageController,
|
||||||
MainButtonConfig,
|
|
||||||
PopupOptions,
|
PopupOptions,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
HapticImpactStyle,
|
HapticImpactStyle,
|
||||||
@@ -20,7 +18,7 @@ const STORAGE_PREFIX = 'bedolaga_';
|
|||||||
function createCapabilities(): PlatformCapabilities {
|
function createCapabilities(): PlatformCapabilities {
|
||||||
return {
|
return {
|
||||||
hasBackButton: false, // No native back button in web
|
hasBackButton: false, // No native back button in web
|
||||||
hasMainButton: false, // No native main button in web
|
|
||||||
hasHapticFeedback: 'vibrate' in navigator, // Web Vibration API
|
hasHapticFeedback: 'vibrate' in navigator, // Web Vibration API
|
||||||
hasNativeDialogs: false, // Use custom dialogs
|
hasNativeDialogs: false, // Use custom dialogs
|
||||||
hasThemeSync: false, // No header/bottom bar sync in web
|
hasThemeSync: false, // No header/bottom bar sync in web
|
||||||
@@ -47,34 +45,6 @@ function createBackButtonController(): BackButtonController {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createMainButtonController(): MainButtonController {
|
|
||||||
// Web doesn't have a native main button - this is a no-op
|
|
||||||
// The UI will render its own submit buttons
|
|
||||||
return {
|
|
||||||
isVisible: false,
|
|
||||||
|
|
||||||
show(_config: MainButtonConfig) {
|
|
||||||
// No-op in web - handled by UI components
|
|
||||||
},
|
|
||||||
|
|
||||||
hide() {
|
|
||||||
// No-op in web
|
|
||||||
},
|
|
||||||
|
|
||||||
showProgress(_show: boolean) {
|
|
||||||
// No-op in web
|
|
||||||
},
|
|
||||||
|
|
||||||
setText(_text: string) {
|
|
||||||
// No-op in web
|
|
||||||
},
|
|
||||||
|
|
||||||
setActive(_active: boolean) {
|
|
||||||
// No-op in web
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function createHapticController(): HapticController {
|
function createHapticController(): HapticController {
|
||||||
// Web Vibration API fallback (works on mobile browsers)
|
// Web Vibration API fallback (works on mobile browsers)
|
||||||
const canVibrate = 'vibrate' in navigator;
|
const canVibrate = 'vibrate' in navigator;
|
||||||
@@ -219,7 +189,7 @@ export function createWebAdapter(): PlatformContext {
|
|||||||
platform: 'web',
|
platform: 'web',
|
||||||
capabilities: createCapabilities(),
|
capabilities: createCapabilities(),
|
||||||
backButton: createBackButtonController(),
|
backButton: createBackButtonController(),
|
||||||
mainButton: createMainButtonController(),
|
|
||||||
haptic: createHapticController(),
|
haptic: createHapticController(),
|
||||||
dialog: createDialogController(),
|
dialog: createDialogController(),
|
||||||
theme: createThemeController(),
|
theme: createThemeController(),
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { usePlatform } from '@/platform/hooks/usePlatform';
|
|
||||||
import type { MainButtonConfig } from '@/platform/types';
|
|
||||||
|
|
||||||
interface UseMainButtonOptions extends Omit<MainButtonConfig, 'onClick'> {
|
|
||||||
/**
|
|
||||||
* Whether the main button should be visible
|
|
||||||
* @default true
|
|
||||||
*/
|
|
||||||
visible?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to manage the Telegram MainButton
|
|
||||||
* Automatically shows/hides based on component lifecycle
|
|
||||||
*
|
|
||||||
* @param onClick - Callback when main button is pressed
|
|
||||||
* @param options - Configuration options
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* function SubmitForm() {
|
|
||||||
* const mutation = useMutation(...);
|
|
||||||
*
|
|
||||||
* useMainButton(handleSubmit, {
|
|
||||||
* text: t('actions.submit'),
|
|
||||||
* isLoading: mutation.isPending,
|
|
||||||
* isActive: isFormValid,
|
|
||||||
* });
|
|
||||||
* }
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useMainButton(
|
|
||||||
onClick: (() => void) | null | undefined,
|
|
||||||
options: UseMainButtonOptions = { text: '' },
|
|
||||||
): void {
|
|
||||||
const { mainButton, capabilities } = usePlatform();
|
|
||||||
const { visible = true, text, isLoading, isActive, color, textColor } = options;
|
|
||||||
|
|
||||||
// Use ref to prevent callback recreation issues
|
|
||||||
const callbackRef = useRef(onClick);
|
|
||||||
callbackRef.current = onClick;
|
|
||||||
|
|
||||||
// Stable callback wrapper
|
|
||||||
const handleClick = useCallback(() => {
|
|
||||||
callbackRef.current?.();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// If no native main button support, do nothing
|
|
||||||
// UI components will render their own submit buttons
|
|
||||||
if (!capabilities.hasMainButton) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If callback is null/undefined or visible is false, hide button
|
|
||||||
if (!onClick || !visible || !text) {
|
|
||||||
mainButton.hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show the main button with configuration
|
|
||||||
mainButton.show({
|
|
||||||
text,
|
|
||||||
onClick: handleClick,
|
|
||||||
isLoading,
|
|
||||||
isActive,
|
|
||||||
color,
|
|
||||||
textColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup: hide button when component unmounts
|
|
||||||
return () => {
|
|
||||||
mainButton.hide();
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
mainButton,
|
|
||||||
capabilities.hasMainButton,
|
|
||||||
handleClick,
|
|
||||||
onClick,
|
|
||||||
visible,
|
|
||||||
text,
|
|
||||||
isLoading,
|
|
||||||
isActive,
|
|
||||||
color,
|
|
||||||
textColor,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook for simple main button usage
|
|
||||||
* Shows button only when conditions are met
|
|
||||||
*
|
|
||||||
* @param text - Button text
|
|
||||||
* @param onClick - Click handler
|
|
||||||
* @param enabled - Whether button should be shown and active
|
|
||||||
* @param loading - Whether to show loading state
|
|
||||||
*/
|
|
||||||
export function useSimpleMainButton(
|
|
||||||
text: string,
|
|
||||||
onClick: () => void,
|
|
||||||
enabled: boolean = true,
|
|
||||||
loading: boolean = false,
|
|
||||||
): void {
|
|
||||||
useMainButton(enabled ? onClick : null, {
|
|
||||||
text,
|
|
||||||
isLoading: loading,
|
|
||||||
isActive: enabled && !loading,
|
|
||||||
visible: enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -10,14 +10,12 @@ export type {
|
|||||||
PlatformType,
|
PlatformType,
|
||||||
PlatformContext as PlatformContextType,
|
PlatformContext as PlatformContextType,
|
||||||
PlatformCapabilities,
|
PlatformCapabilities,
|
||||||
MainButtonConfig,
|
|
||||||
PopupOptions,
|
PopupOptions,
|
||||||
PopupButton,
|
PopupButton,
|
||||||
InvoiceStatus,
|
InvoiceStatus,
|
||||||
HapticImpactStyle,
|
HapticImpactStyle,
|
||||||
HapticNotificationType,
|
HapticNotificationType,
|
||||||
BackButtonController,
|
BackButtonController,
|
||||||
MainButtonController,
|
|
||||||
HapticController,
|
HapticController,
|
||||||
DialogController,
|
DialogController,
|
||||||
ThemeController,
|
ThemeController,
|
||||||
@@ -28,7 +26,6 @@ export type {
|
|||||||
// Hooks
|
// Hooks
|
||||||
export { usePlatform, useIsTelegram, useCapability } from './hooks/usePlatform';
|
export { usePlatform, useIsTelegram, useCapability } from './hooks/usePlatform';
|
||||||
export { useBackButton, useConditionalBackButton } from './hooks/useBackButton';
|
export { useBackButton, useConditionalBackButton } from './hooks/useBackButton';
|
||||||
export { useMainButton, useSimpleMainButton } from './hooks/useMainButton';
|
|
||||||
export { useHaptic, useHapticClick, useHapticFeedback } from './hooks/useHaptic';
|
export { useHaptic, useHapticClick, useHapticFeedback } from './hooks/useHaptic';
|
||||||
export { useNativeDialog, useDestructiveConfirm, PopupButtons } from './hooks/useNativeDialog';
|
export { useNativeDialog, useDestructiveConfirm, PopupButtons } from './hooks/useNativeDialog';
|
||||||
export { useNotify } from './hooks/useNotify';
|
export { useNotify } from './hooks/useNotify';
|
||||||
|
|||||||
@@ -7,15 +7,6 @@ export type HapticNotificationType = 'success' | 'warning' | 'error';
|
|||||||
|
|
||||||
export type InvoiceStatus = 'paid' | 'cancelled' | 'failed' | 'pending';
|
export type InvoiceStatus = 'paid' | 'cancelled' | 'failed' | 'pending';
|
||||||
|
|
||||||
export interface MainButtonConfig {
|
|
||||||
text: string;
|
|
||||||
onClick: () => void;
|
|
||||||
isLoading?: boolean;
|
|
||||||
isActive?: boolean;
|
|
||||||
color?: string;
|
|
||||||
textColor?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PopupButton {
|
export interface PopupButton {
|
||||||
id: string;
|
id: string;
|
||||||
type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive';
|
type?: 'default' | 'ok' | 'close' | 'cancel' | 'destructive';
|
||||||
@@ -30,7 +21,7 @@ export interface PopupOptions {
|
|||||||
|
|
||||||
export interface PlatformCapabilities {
|
export interface PlatformCapabilities {
|
||||||
hasBackButton: boolean;
|
hasBackButton: boolean;
|
||||||
hasMainButton: boolean;
|
|
||||||
hasHapticFeedback: boolean;
|
hasHapticFeedback: boolean;
|
||||||
hasNativeDialogs: boolean;
|
hasNativeDialogs: boolean;
|
||||||
hasThemeSync: boolean;
|
hasThemeSync: boolean;
|
||||||
@@ -46,15 +37,6 @@ export interface BackButtonController {
|
|||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MainButtonController {
|
|
||||||
show: (config: MainButtonConfig) => void;
|
|
||||||
hide: () => void;
|
|
||||||
showProgress: (show: boolean) => void;
|
|
||||||
setText: (text: string) => void;
|
|
||||||
setActive: (active: boolean) => void;
|
|
||||||
isVisible: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface HapticController {
|
export interface HapticController {
|
||||||
impact: (style?: HapticImpactStyle) => void;
|
impact: (style?: HapticImpactStyle) => void;
|
||||||
notification: (type: HapticNotificationType) => void;
|
notification: (type: HapticNotificationType) => void;
|
||||||
@@ -104,9 +86,6 @@ export interface PlatformContext {
|
|||||||
// Navigation
|
// Navigation
|
||||||
backButton: BackButtonController;
|
backButton: BackButtonController;
|
||||||
|
|
||||||
// Main action button
|
|
||||||
mainButton: MainButtonController;
|
|
||||||
|
|
||||||
// Haptic feedback
|
// Haptic feedback
|
||||||
haptic: HapticController;
|
haptic: HapticController;
|
||||||
|
|
||||||
|
|||||||
@@ -1436,7 +1436,7 @@ input[type='checkbox']:hover:not(:checked) {
|
|||||||
.wave-blob-1 {
|
.wave-blob-1 {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: 400px;
|
height: 400px;
|
||||||
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.6) 0%, transparent 70%);
|
background: radial-gradient(circle, rgba(var(--color-accent-800), 0.6) 0%, transparent 70%);
|
||||||
top: -15%;
|
top: -15%;
|
||||||
left: -10%;
|
left: -10%;
|
||||||
animation: wave1 20s ease-in-out infinite; /* Slower = less CPU */
|
animation: wave1 20s ease-in-out infinite; /* Slower = less CPU */
|
||||||
@@ -1517,7 +1517,7 @@ input[type='checkbox']:hover:not(:checked) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.light .wave-blob-1 {
|
.light .wave-blob-1 {
|
||||||
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.7) 0%, transparent 70%);
|
background: radial-gradient(circle, rgba(var(--color-accent-800), 0.7) 0%, transparent 70%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile: brighter and faster animations */
|
/* Mobile: brighter and faster animations */
|
||||||
@@ -1530,7 +1530,7 @@ input[type='checkbox']:hover:not(:checked) {
|
|||||||
.wave-blob-1 {
|
.wave-blob-1 {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
background: radial-gradient(circle, rgba(var(--color-accent-500), 0.8) 0%, transparent 70%);
|
background: radial-gradient(circle, rgba(var(--color-accent-800), 0.8) 0%, transparent 70%);
|
||||||
animation: wave1-mobile 12s ease-in-out infinite;
|
animation: wave1-mobile 12s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user