fix(support): не склеивать t.me со ссылкой на внешний хелпдеск

SUPPORT_USERNAME на бэке принимает и @username, и произвольный URL. Кабинет
же во всех трёх ветках клеил `https://t.me/${support_username}`, из-за чего
внешний хелпдеск превращался в https://t.me/https://help.example.com и не
открывался. Бэк теперь отдаёт контакт разрезолвленным (support_url +
contact_is_telegram) — клеить на клиенте нечего.

Логика вынесена в resolveSupportContact: три копии склейки (profile, fallback
и карточка режима both) заменены одним вызовом. Старый бэк не шлёт support_url,
для него сохранено прежнее поведение — новый фронт не ломается на неподнятом
бэкенде.

Заодно ушли три non-null assertion на support_username.
This commit is contained in:
Fringg
2026-07-20 21:41:08 +03:00
parent b97c3dc2f2
commit f04675b249
4 changed files with 125 additions and 30 deletions

View File

@@ -1,5 +1,5 @@
import { uiLocale } from '@/utils/uiLocale'; import { uiLocale } from '@/utils/uiLocale';
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
@@ -9,13 +9,14 @@ import { infoApi } from '../api/info';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit'; import { checkRateLimit, getRateLimitResetTime, RATE_LIMIT_KEYS } from '../utils/rateLimit';
import type { TicketDetail } from '../types'; import type { SupportConfig, TicketDetail } 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';
import { staggerContainer, staggerItem } from '@/components/motion/transitions'; import { staggerContainer, staggerItem } from '@/components/motion/transitions';
import { ChatIcon, CloseIcon, ImageIcon, PlusIcon, SendIcon } from '@/components/icons'; import { ChatIcon, CloseIcon, ImageIcon, PlusIcon, SendIcon } from '@/components/icons';
import { usePlatform } from '@/platform'; import { usePlatform } from '@/platform';
import { linkifyText } from '../utils/linkify'; import { linkifyText } from '../utils/linkify';
import { resolveSupportContact } from '../utils/supportContact';
const log = logger.createLogger('Support'); const log = logger.createLogger('Support');
@@ -36,6 +37,18 @@ export default function Support() {
const isAdmin = useAuthStore((state) => state.isAdmin); const isAdmin = useAuthStore((state) => state.isAdmin);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { openTelegramLink, openLink } = usePlatform(); const { openTelegramLink, openLink } = usePlatform();
const openSupportContact = useCallback(
(config: SupportConfig) => {
const target = resolveSupportContact(config);
if (target.kind === 'external') {
openLink(target.url, { tryInstantView: false });
} else {
openTelegramLink(target.url);
}
},
[openLink, openTelegramLink],
);
const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null); const [selectedTicket, setSelectedTicket] = useState<TicketDetail | null>(null);
const [showCreateForm, setShowCreateForm] = useState(false); const [showCreateForm, setShowCreateForm] = useState(false);
const [newTitle, setNewTitle] = useState(''); const [newTitle, setNewTitle] = useState('');
@@ -211,17 +224,7 @@ export default function Support() {
buttonText: t('support.contactUs'), buttonText: t('support.contactUs'),
buttonAction: () => { buttonAction: () => {
log.debug('Button clicked, opening:', supportUsername); log.debug('Button clicked, opening:', supportUsername);
openSupportContact(supportConfig);
// Extract username without @
const username = supportUsername.startsWith('@')
? supportUsername.slice(1)
: supportUsername;
const webUrl = `https://t.me/${username}`;
log.debug('Web URL:', webUrl);
// Use platform's openTelegramLink
openTelegramLink(webUrl);
}, },
}; };
} }
@@ -246,17 +249,7 @@ export default function Support() {
buttonText: t('support.contactUs'), buttonText: t('support.contactUs'),
buttonAction: () => { buttonAction: () => {
log.debug('Fallback button clicked, opening:', supportUsername); log.debug('Fallback button clicked, opening:', supportUsername);
openSupportContact(supportConfig);
// Extract username without @
const username = supportUsername.startsWith('@')
? supportUsername.slice(1)
: supportUsername;
const webUrl = `https://t.me/${username}`;
log.debug('Fallback opening URL:', webUrl);
// Use platform's openTelegramLink
openTelegramLink(webUrl);
}, },
}; };
}; };
@@ -367,12 +360,7 @@ export default function Support() {
<Button <Button
variant="secondary" variant="secondary"
className="shrink-0 whitespace-nowrap" className="shrink-0 whitespace-nowrap"
onClick={() => { onClick={() => openSupportContact(supportConfig)}
const username = supportConfig.support_username!.startsWith('@')
? supportConfig.support_username!.slice(1)
: supportConfig.support_username!;
openTelegramLink(`https://t.me/${username}`);
}}
> >
{t('support.writeButton', 'Написать')} {t('support.writeButton', 'Написать')}
</Button> </Button>

View File

@@ -530,6 +530,8 @@ export interface SupportConfig {
support_type: 'tickets' | 'profile' | 'url' | 'both'; support_type: 'tickets' | 'profile' | 'url' | 'both';
support_url?: string | null; support_url?: string | null;
support_username?: string | null; support_username?: string | null;
/** Резолвнутый контакт ведёт в Telegram, а не на внешний хелпдеск. */
contact_is_telegram?: boolean;
} }
// Paginated response // Paginated response

View File

@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import type { SupportConfig } from '../types';
import { resolveSupportContact } from './supportContact';
/**
* Кабинет склеивал `https://t.me/${support_username}` и ломал внешний хелпдеск:
* SUPPORT_USERNAME принимает и `@user`, и произвольный URL, поэтому из
* `https://help.example.com` получалось `https://t.me/https://help.example.com`.
* Бэк отдаёт контакт уже разрезолвленным — клеить на клиенте больше нечего.
*/
const config = (overrides: Partial<SupportConfig>): SupportConfig => ({
tickets_enabled: true,
support_type: 'both',
...overrides,
});
describe('resolveSupportContact', () => {
it('внешний хелпдеск открывается как обычная ссылка, без t.me', () => {
const target = resolveSupportContact(
config({
support_url: 'https://help.example.com',
support_username: 'https://help.example.com',
contact_is_telegram: false,
}),
);
expect(target).toEqual({ kind: 'external', url: 'https://help.example.com' });
expect(target.url).not.toContain('t.me');
});
it('телеграм-контакт открывается через telegram-ссылку', () => {
const target = resolveSupportContact(
config({
support_url: 'https://t.me/help',
support_username: '@help',
contact_is_telegram: true,
}),
);
expect(target).toEqual({ kind: 'telegram', url: 'https://t.me/help' });
});
describe('старый бэк без support_url — прежнее поведение', () => {
it.each([
['@help', 'https://t.me/help'],
['help', 'https://t.me/help'],
])('%s -> %s', (username, expected) => {
const target = resolveSupportContact(config({ support_username: username }));
expect(target).toEqual({ kind: 'telegram', url: expected });
});
it('контакт вообще не задан — дефолтный @support', () => {
expect(resolveSupportContact(config({}))).toEqual({
kind: 'telegram',
url: 'https://t.me/support',
});
});
});
it('support_url без contact_is_telegram трактуется как телеграм', () => {
// Комбинация недостижима с текущим бэком, но деградировать надо в старое
// поведение, а не в переход по внешней ссылке.
const target = resolveSupportContact(config({ support_url: 'https://t.me/help' }));
expect(target.kind).toBe('telegram');
});
});

View File

@@ -0,0 +1,36 @@
import type { SupportConfig } from '../types';
/**
* Куда вести пользователя по кнопке «Написать в поддержку».
*
* Telegram-ссылку открывают через openTelegramLink, внешнюю — обычным переходом.
*/
export type SupportContactTarget = {
kind: 'telegram' | 'external';
url: string;
};
/**
* Резолвит контакт поддержки из конфига.
*
* SUPPORT_USERNAME на бэке принимает и `@username`, и произвольный URL, поэтому
* бэк отдаёт уже разрезолвленные `support_url` + `contact_is_telegram`. Клиент
* раньше склеивал `https://t.me/${support_username}` — на внешнем хелпдеске это
* давало `https://t.me/https://help.example.com`, и ссылка не открывалась.
*
* Старый бэк не отдаёт `support_url`; для него сохранено прежнее поведение,
* иначе новый фронт сломался бы на неподнятом бэкенде.
*/
export function resolveSupportContact(config: SupportConfig): SupportContactTarget {
if (config.support_url) {
// contact_is_telegram отсутствует только у старого бэка, а он не шлёт и
// support_url — так что явная проверка на false, а не на truthy.
const kind = config.contact_is_telegram === false ? 'external' : 'telegram';
return { kind, url: config.support_url };
}
const raw = config.support_username || '@support';
const username = raw.startsWith('@') ? raw.slice(1) : raw;
return { kind: 'telegram', url: `https://t.me/${username}` };
}