Merge pull request #390 from BEDOLAGA-DEV/dev

Bugfixes: polyfill, UI fixes, campaign tracking, menu editor
This commit is contained in:
c0mrade
2026-04-13 19:37:21 +03:00
committed by GitHub
9 changed files with 77 additions and 4 deletions

View File

@@ -76,6 +76,8 @@ export interface EmailBroadcastCreateRequest {
export interface CombinedBroadcastCreateRequest {
channel: BroadcastChannel;
target: string;
// Broadcast category for user notification preference filtering
category?: 'system' | 'news' | 'promo';
// Telegram fields
message_text?: string;
selected_buttons?: string[];
@@ -111,6 +113,8 @@ export interface Broadcast {
created_at: string;
completed_at: string | null;
progress_percent: number;
// Broadcast category
category?: 'system' | 'news' | 'promo';
// New fields for channel support
channel?: BroadcastChannel;
email_subject?: string | null;

View File

@@ -97,6 +97,7 @@ export const authApi = {
first_name?: string;
language?: string;
referral_code?: string;
campaign_slug?: string;
}): Promise<RegisterResponse> => {
const response = await apiClient.post<RegisterResponse>(
'/cabinet/auth/email/register/standalone',

View File

@@ -573,8 +573,10 @@ export function MenuEditorTab() {
setDraftConfig(data);
queryClient.setQueryData(['menu-layout'], data);
},
onError: () => {
notify.error(t('common.error'));
onError: (err: unknown) => {
const error = err as { response?: { data?: { detail?: string } } };
const detail = error.response?.data?.detail;
notify.error(detail || t('common.error'));
},
});
@@ -748,7 +750,12 @@ export function MenuEditorTab() {
for (const row of currentDraft.rows) {
for (const btn of row.buttons) {
if (btn.type === 'custom') {
if (!btn.url || (!btn.url.startsWith('http://') && !btn.url.startsWith('https://'))) {
if (
!btn.url ||
(!btn.url.startsWith('http://') &&
!btn.url.startsWith('https://') &&
!btn.url.startsWith('tg://'))
) {
notify.error(t('admin.menuEditor.invalidUrl'));
return;
}

View File

@@ -27,6 +27,16 @@ import { getCachedFullscreenEnabled, isTelegramMobile } from './hooks/useTelegra
import './i18n';
import './styles/globals.css';
// Polyfill Object.hasOwn for older iOS/Android WebViews (Safari < 15.4, old Chrome).
// @telegram-apps/sdk v3 depends on valibot which uses Object.hasOwn internally.
// Without this, init() throws LaunchParamsRetrieveError on affected devices.
// See: https://github.com/Telegram-Mini-Apps/tma.js/issues/683
if (typeof (Object as { hasOwn?: unknown }).hasOwn !== 'function') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(Object as any).hasOwn = (obj: object, prop: PropertyKey): boolean =>
Object.prototype.hasOwnProperty.call(obj, prop);
}
// Only initialize Telegram SDK when running inside Telegram
const isTelegramEnv =
!!(window as unknown as Record<string, unknown>).TelegramWebviewProxy ||

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { AdminBackButton } from '../components/admin/AdminBackButton';
import {
banSystemApi,
type BanSystemStatus,
@@ -557,6 +558,7 @@ export default function AdminBanSystem() {
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<AdminBackButton />
<div className="rounded-xl bg-error-500/20 p-3">
<ShieldIcon />
</div>

View File

@@ -128,6 +128,9 @@ export default function AdminBroadcastCreate() {
const [showTelegramFilters, setShowTelegramFilters] = useState(false);
const [showEmailFilters, setShowEmailFilters] = useState(false);
// Broadcast category (system/news/promo)
const [category, setCategory] = useState<'system' | 'news' | 'promo'>('system');
// Telegram-specific state
const [messageText, setMessageText] = useState('');
const [selectedButtons, setSelectedButtons] = useState<string[]>(['home']);
@@ -395,6 +398,7 @@ export default function AdminBroadcastCreate() {
message_text: messageText,
selected_buttons: selectedButtons,
custom_buttons: customButtons.length > 0 ? customButtons : undefined,
category,
};
if (uploadedFileId) {
data.media = { type: mediaType, file_id: uploadedFileId };
@@ -409,6 +413,7 @@ export default function AdminBroadcastCreate() {
target: emailTarget,
email_subject: emailSubject,
email_html_content: emailContent,
category,
};
createMutation.mutate(data);
return;
@@ -423,6 +428,7 @@ export default function AdminBroadcastCreate() {
message_text: messageText,
selected_buttons: selectedButtons,
custom_buttons: customButtons.length > 0 ? customButtons : undefined,
category,
};
if (uploadedFileId) {
telegramData.media = { type: mediaType, file_id: uploadedFileId };
@@ -433,6 +439,7 @@ export default function AdminBroadcastCreate() {
target: emailTarget,
email_subject: emailSubject,
email_html_content: emailContent,
category,
};
await adminBroadcastsApi.createCombined(telegramData);
@@ -583,6 +590,36 @@ export default function AdminBroadcastCreate() {
)}
</div>
{/* Broadcast category */}
<div className="card">
<label className="mb-3 block text-sm font-medium text-dark-300">
{t('admin.broadcasts.category', 'Категория рассылки')}
</label>
<p className="mb-3 text-xs text-dark-500">
{t(
'admin.broadcasts.categoryDesc',
'Пользователи могут отключить получение новостей и промо в настройках профиля. Системные рассылки доставляются всем.',
)}
</p>
<div className="flex gap-3">
{(['system', 'news', 'promo'] as const).map((cat) => (
<button
key={cat}
onClick={() => setCategory(cat)}
className={`flex-1 rounded-lg border p-3 text-sm font-medium transition-all ${
category === cat
? 'border-accent-500 bg-accent-500/10 text-accent-400'
: 'border-dark-700 bg-dark-800 text-dark-300 hover:border-dark-600'
}`}
>
{cat === 'system' && t('admin.broadcasts.categorySystem', '⚙️ Системное')}
{cat === 'news' && t('admin.broadcasts.categoryNews', '📰 Новости')}
{cat === 'promo' && t('admin.broadcasts.categoryPromo', '🎁 Промо')}
</button>
))}
</div>
</div>
{/* Telegram section */}
{telegramEnabled && (
<div className="card space-y-6">

View File

@@ -720,6 +720,16 @@ export default function Login() {
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
{authMode === 'register' &&
password.length > 0 &&
password.length < 8 && (
<p className="mt-1.5 text-xs text-error-400">
{t(
'auth.passwordTooShort',
'Password must be at least 8 characters',
)}
</p>
)}
</div>
{authMode === 'register' && (

View File

@@ -584,7 +584,7 @@ export default function Subscription() {
{/* Status badge */}
<span
className="rounded-full px-3 py-1 font-mono text-[10px] font-semibold uppercase tracking-wider"
className="max-w-[55%] shrink-0 rounded-full px-3 py-1 text-center font-mono text-[10px] font-semibold uppercase tracking-wider"
style={{
background: subscription.is_active
? `${zone.mainHex}15`

View File

@@ -359,12 +359,14 @@ export const useAuthStore = create<AuthState>()(
registerWithEmail: async (email, password, firstName, referralCode) => {
const code = referralCode || getPendingReferralCode() || undefined;
const campaignSlug = getPendingCampaignSlug() || undefined;
const response = await authApi.registerEmailStandalone({
email,
password,
first_name: firstName,
language: navigator.language.split('-')[0] || 'ru',
referral_code: code,
campaign_slug: campaignSlug,
});
consumeReferralCode();
return response;