mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
69
src/utils/supportContact.test.ts
Normal file
69
src/utils/supportContact.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
36
src/utils/supportContact.ts
Normal file
36
src/utils/supportContact.ts
Normal 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}` };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user