From ff0b119ebc02ecf053f568d53fc77363a31f1338 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 19 Jun 2026 12:57:27 +0300 Subject: [PATCH] feat(admin-tickets): deep-link to a specific ticket from notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an /admin/tickets/:ticketId route that pre-selects the ticket, and a StartParamNavigator that maps a Telegram Mini App start param (admin_ticket_, delivered by a group-chat t.me//?startapp deep link) to that route. This lets the bot's admin ticket notification buttons open the cabinet straight on the ticket — web_app buttons for private chats, the startapp deep link for groups where web_app is unavailable. Selection mirrors the URL param (cleared on the bare /admin/tickets list). The static /admin/tickets/settings route still out-ranks the new dynamic segment. Pairs with bot feature BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot#2988. --- src/App.tsx | 13 +++++++++++++ src/AppWithNavigator.tsx | 40 ++++++++++++++++++++++++++++++++++++++ src/pages/AdminTickets.tsx | 21 +++++++++++++++++++- 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index abbd610..ce45aa3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -632,6 +632,19 @@ function App() { } /> + {/* Deep-link target for admin ticket notification buttons (bot issue #2988): + opens a specific ticket directly. Static "/settings" above out-ranks + this dynamic segment in react-router, so there is no conflict. */} + + + + + + } + /> ` startapp param → /admin/tickets/. */ +const ADMIN_TICKET_START_PARAM_RE = /^admin_ticket_(\d+)$/; + +/** + * Routes a Telegram Mini App start param to an in-app destination on launch. + * + * Admin ticket notification buttons in GROUP/channel chats open the cabinet via + * a `t.me//?startapp=admin_ticket_` deep link (bot issue #2988) — + * `web_app` buttons don't work in group chats, so the startapp param is the only + * way in. Telegram delivers it as `tgWebAppStartParam`; we map it to the admin + * ticket route once on mount. Access is still gated by the route's + * `PermissionRoute permission="tickets:read"`. + */ +function StartParamNavigator() { + const navigate = useNavigate(); + const handled = useRef(false); + + useEffect(() => { + if (handled.current) return; + handled.current = true; + + let startParam: string | undefined; + try { + startParam = retrieveLaunchParams().tgWebAppStartParam; + } catch { + return; + } + if (!startParam) return; + + const match = ADMIN_TICKET_START_PARAM_RE.exec(startParam); + if (match) { + navigate(`/admin/tickets/${match[1]}`, { replace: true }); + } + }, [navigate]); + + return null; +} + export function AppWithNavigator() { const isTelegram = isInTelegramWebApp(); return ( {isTelegram && } + {isTelegram && } diff --git a/src/pages/AdminTickets.tsx b/src/pages/AdminTickets.tsx index 2f1c223..1e6a040 100644 --- a/src/pages/AdminTickets.tsx +++ b/src/pages/AdminTickets.tsx @@ -2,7 +2,7 @@ import { useState, useRef, useEffect } from 'react'; import logger from '../utils/logger'; import { linkifyText } from '../utils/linkify'; import { MessageMediaGrid } from '../components/tickets/MessageMediaGrid'; -import { useNavigate } from 'react-router'; +import { useNavigate, useParams } from 'react-router'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { adminApi, AdminTicket, AdminTicketDetail } from '../api/admin'; @@ -53,10 +53,29 @@ const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB export default function AdminTickets() { const { t } = useTranslation(); const navigate = useNavigate(); + const { ticketId } = useParams<{ ticketId: string }>(); const queryClient = useQueryClient(); const { capabilities } = usePlatform(); const [selectedTicketId, setSelectedTicketId] = useState(null); + + // Deep-link: /admin/tickets/:ticketId (or a startapp param routed here) opens + // the given ticket directly — used by the admin-chat notification buttons. + // Both routes render the same component instance (no remount), so we mirror the + // URL param into the selection: navigating to the bare /admin/tickets list + // clears any deep-linked selection, keeping URL and detail pane in sync. (This + // only fires on mount or an actual param change, never on in-list clicks, since + // ticketId stays undefined on the bare route.) + useEffect(() => { + if (!ticketId) { + setSelectedTicketId(null); + return; + } + const id = Number(ticketId); + if (Number.isInteger(id) && id > 0) { + setSelectedTicketId(id); + } + }, [ticketId]); const [statusFilter, setStatusFilter] = useState(''); const [replyText, setReplyText] = useState(''); const [isReplying, setIsReplying] = useState(false);