Support chat mobile UX: fix keyboard overlap and improve composer.

Hide bottom navigation while typing and in support chat mode, adapt chat layout to visual viewport/keyboard insets, and enlarge the message composer so input remains visible and comfortable in TG/MAX mobile webviews.
This commit is contained in:
Fedor
2026-03-02 08:22:26 +03:00
parent 66a0065df8
commit e630d03e67
8 changed files with 272 additions and 44 deletions

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>Clientright — защита прав потребителей</title>
<!-- Подключаем только скрипт текущей платформы, иначе в MAX приходят события Telegram → UnsupportedEvent -->
<script>

View File

@@ -2,6 +2,8 @@
min-height: 100vh;
display: flex;
flex-direction: column;
overflow-x: hidden;
max-width: 100%;
}
.app-header {
@@ -27,8 +29,10 @@
flex: 1;
max-width: 1200px;
width: 100%;
min-width: 0;
margin: 0 auto;
padding: 2rem;
overflow-x: hidden;
}
.card {

View File

@@ -4,8 +4,7 @@
left: 0;
right: 0;
width: 100%;
min-width: 100%;
max-width: 100vw;
max-width: 100%;
box-sizing: border-box;
min-height: 64px;
height: calc(64px + env(safe-area-inset-bottom, 0));
@@ -19,8 +18,14 @@
align-items: center;
justify-content: space-around;
z-index: 100;
transition: transform 0.2s ease, opacity 0.2s ease;
}
.app-bottom-bar--hidden {
transform: translateY(120%);
opacity: 0;
pointer-events: none;
}
.app-bar-item {
display: flex;
flex-direction: column;

View File

@@ -27,6 +27,9 @@ export default function BottomBar({ currentPath, avatarUrl, profileNeedsAttentio
const isSupport = currentPath === '/support';
const [backEnabled, setBackEnabled] = useState(false);
const [supportUnreadCount, setSupportUnreadCount] = useState(0);
const [keyboardOpen, setKeyboardOpen] = useState(false);
const [inputFocused, setInputFocused] = useState(false);
const [supportChatMode, setSupportChatMode] = useState(false);
// Непрочитанные в поддержке — для бейджа на иконке
useEffect(() => {
@@ -55,6 +58,61 @@ export default function BottomBar({ currentPath, avatarUrl, profileNeedsAttentio
return () => window.clearTimeout(t);
}, [isHome, isProfile, currentPath]);
// Если открыта клавиатура — прячем нижний бар, чтобы он не перекрывал поле ввода
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
setKeyboardOpen(inset > 80);
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, []);
// Универсально для любых WebView: если в фокусе поле ввода, нижний бар скрываем.
useEffect(() => {
const isEditable = (el: EventTarget | null): boolean => {
if (!(el instanceof HTMLElement)) return false;
const tag = el.tagName.toLowerCase();
return tag === 'input' || tag === 'textarea' || el.isContentEditable;
};
const handleFocusIn = (e: FocusEvent) => {
if (isEditable(e.target)) setInputFocused(true);
};
const handleFocusOut = () => {
window.setTimeout(() => {
const active = document.activeElement;
setInputFocused(isEditable(active));
}, 30);
};
window.addEventListener('focusin', handleFocusIn);
window.addEventListener('focusout', handleFocusOut);
return () => {
window.removeEventListener('focusin', handleFocusIn);
window.removeEventListener('focusout', handleFocusOut);
};
}, []);
useEffect(() => {
const onSupportChatMode = (e: Event) => {
const detail = (e as CustomEvent<{ active?: boolean }>).detail;
setSupportChatMode(!!detail?.active);
};
window.addEventListener('miniapp:supportChatMode', onSupportChatMode as EventListener);
return () => {
window.removeEventListener('miniapp:supportChatMode', onSupportChatMode as EventListener);
};
}, []);
const handleBack = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
@@ -148,7 +206,10 @@ export default function BottomBar({ currentPath, avatarUrl, profileNeedsAttentio
};
return (
<nav className="app-bottom-bar" aria-label="Навигация">
<nav
className={`app-bottom-bar${keyboardOpen || inputFocused || supportChatMode ? ' app-bottom-bar--hidden' : ''}`}
aria-label="Навигация"
>
{!isHome && !isProfile && (
<button
type="button"

View File

@@ -5,7 +5,7 @@
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Form, Input, Spin, Typography } from 'antd';
import { Button, Form, Input, message, Spin, Typography } from 'antd';
import { Paperclip, X } from 'lucide-react';
const { TextArea } = Input;
@@ -73,11 +73,30 @@ export default function SupportChat({
const [form] = Form.useForm();
const [files, setFiles] = useState<File[]>([]);
const [fileInputKey, setFileInputKey] = useState(0);
const [keyboardInset, setKeyboardInset] = useState(0);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputBarRef = useRef<HTMLDivElement>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const threadIdRef = useRef<string | null>(null);
threadIdRef.current = threadId;
// При фокусе: в TG/MAX запрашиваем expand(); затем прокручиваем поле ввода в видимую зону (над клавиатурой)
const scrollInputIntoView = useCallback(() => {
const win = typeof window !== 'undefined' ? window : null;
const tg = (win as unknown as { Telegram?: { WebApp?: { expand?: () => void } } })?.Telegram?.WebApp;
const max = (win as unknown as { WebApp?: { expand?: () => void } })?.WebApp;
if (tg?.expand) tg.expand();
if (max?.expand) max.expand();
const scroll = () => inputBarRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' });
const t1 = window.setTimeout(scroll, 350);
const t2 = window.setTimeout(scroll, 700);
return () => {
window.clearTimeout(t1);
window.clearTimeout(t2);
};
}, []);
const markRead = useCallback((tid: string) => {
const token = getSessionToken();
if (!token) return;
@@ -146,6 +165,22 @@ export default function SupportChat({
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const update = () => {
const inset = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
setKeyboardInset(inset);
};
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, []);
const handleSend = async () => {
const values = await form.getFieldsValue();
const text = (values.message || '').trim();
@@ -169,7 +204,13 @@ export default function SupportChat({
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
const detail = err.detail || res.statusText;
if (res.status === 503) {
message.error('Сервис поддержки временно недоступен. Попробуйте позже.');
} else {
message.error(typeof detail === 'string' ? detail : 'Не удалось отправить сообщение.');
}
return;
}
const data = await res.json();
if (data.thread_id) setThreadId(data.thread_id);
@@ -179,6 +220,7 @@ export default function SupportChat({
setFileInputKey((k) => k + 1);
} catch (e) {
console.error(e);
message.error('Ошибка соединения. Попробуйте ещё раз.');
} finally {
setSubmitting(false);
}
@@ -206,7 +248,13 @@ export default function SupportChat({
const res = await fetch('/api/v1/support', { method: 'POST', body: fd });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || res.statusText);
const detail = err.detail || res.statusText;
if (res.status === 503) {
message.error('Сервис поддержки временно недоступен. Попробуйте позже.');
} else {
message.error(typeof detail === 'string' ? detail : 'Не удалось отправить обращение.');
}
return;
}
const data = await res.json();
if (data.thread_id) setThreadId(data.thread_id);
@@ -217,6 +265,7 @@ export default function SupportChat({
onSuccess?.();
} catch (e) {
console.error(e);
message.error('Ошибка соединения. Попробуйте ещё раз.');
} finally {
setSubmitting(false);
}
@@ -241,7 +290,10 @@ export default function SupportChat({
if (!showChat) {
return (
<div className={compact ? 'support-chat support-chat--compact' : 'support-chat'}>
<div
className={compact ? 'support-chat support-chat--compact' : 'support-chat'}
style={{ paddingBottom: keyboardInset ? keyboardInset + 8 : 8 }}
>
{claimId && !hideClaimLabel && (
<p style={{ marginBottom: 12, color: '#666', fontSize: 13 }}>По обращению {claimId}</p>
)}
@@ -251,7 +303,7 @@ export default function SupportChat({
label="Сообщение"
rules={[{ required: true, message: 'Введите текст' }]}
>
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос..." maxLength={5000} showCount />
<TextArea rows={compact ? 3 : 5} placeholder="Опишите вопрос..." maxLength={5000} showCount onFocus={scrollInputIntoView} />
</Form.Item>
<Form.Item name="subject" label="Тема (необязательно)">
<Input placeholder="Краткая тема" maxLength={200} />
@@ -313,6 +365,7 @@ export default function SupportChat({
display: 'flex',
flexDirection: 'column',
gap: 12,
paddingBottom: keyboardInset ? keyboardInset + 8 : 8,
}}
>
{messages.map((msg) => (
@@ -345,13 +398,26 @@ export default function SupportChat({
<div ref={messagesEndRef} />
</div>
<Form form={form} onFinish={handleSend} style={{ flexShrink: 0 }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div
ref={inputBarRef}
style={{
display: 'flex',
flexDirection: 'column',
gap: 8,
paddingTop: 8,
borderTop: '1px solid #f0f0f0',
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
paddingBottom: keyboardInset ? keyboardInset : 0,
background: '#fff',
}}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-end' }}>
<Form.Item name="message" style={{ flex: 1, marginBottom: 0 }}>
<TextArea
placeholder="Сообщение..."
autoSize={{ minRows: 1, maxRows: 4 }}
autoSize={{ minRows: 2, maxRows: 6 }}
maxLength={5000}
onFocus={scrollInputIntoView}
onPressEnter={(e) => {
if (!e.shiftKey) {
e.preventDefault();
@@ -371,9 +437,10 @@ export default function SupportChat({
<Button
type="button"
icon={<Paperclip size={18} />}
size="large"
onClick={() => document.getElementById('support-chat-files-chat')?.click()}
/>
<Button type="primary" htmlType="submit" loading={submitting}>
<Button type="primary" htmlType="submit" loading={submitting} size="large">
Отправить
</Button>
</div>

View File

@@ -4,15 +4,26 @@
box-sizing: border-box;
}
html {
overflow-x: hidden;
max-width: 100%;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: #ffffff;
overflow-x: hidden;
max-width: 100%;
position: relative;
overflow-wrap: break-word;
}
#root {
min-height: 100vh;
overflow-x: hidden;
max-width: 100%;
}

View File

@@ -3,7 +3,7 @@
*/
import { useEffect, useState } from 'react';
import { Button, List, Spin, Typography } from 'antd';
import { Button, List, Spin, Typography, message } from 'antd';
import { ArrowLeft, MessageCirclePlus } from 'lucide-react';
import SupportChat from '../components/SupportChat';
@@ -37,11 +37,18 @@ interface SupportProps {
onNavigate?: (path: string) => void;
}
function getViewportHeight(): number {
if (typeof window === 'undefined') return 600;
const vv = window.visualViewport;
return (vv?.height ?? window.innerHeight) || 600;
}
export default function Support({ onNavigate }: SupportProps) {
const [view, setView] = useState<'list' | 'chat'>('list');
const [threads, setThreads] = useState<SupportThreadItem[]>([]);
const [loading, setLoading] = useState(true);
const [selectedClaimId, setSelectedClaimId] = useState<string | null | undefined>(undefined);
const [viewportHeight, setViewportHeight] = useState(getViewportHeight);
useEffect(() => {
if (view !== 'list') return;
@@ -53,11 +60,24 @@ export default function Support({ onNavigate }: SupportProps) {
const params = new URLSearchParams();
params.set('session_token', token);
fetch(`/api/v1/support/threads?${params.toString()}`)
.then((res) => (res.ok ? res.json() : { threads: [] }))
.then((res) => {
if (res.status === 401) {
message.error('Сессия истекла. Обновите страницу или войдите снова.');
return { threads: [] };
}
if (!res.ok) {
message.error('Не удалось загрузить список обращений. Попробуйте позже.');
return { threads: [] };
}
return res.json();
})
.then((data) => {
setThreads(data.threads || []);
})
.catch(() => setThreads([]))
.catch(() => {
message.error('Ошибка соединения. Проверьте интернет и попробуйте снова.');
setThreads([]);
})
.finally(() => setLoading(false));
}, [view]);
@@ -84,29 +104,70 @@ export default function Support({ onNavigate }: SupportProps) {
return () => window.removeEventListener('miniapp:goBack', onGoBack);
}, [view, onNavigate]);
useEffect(() => {
window.dispatchEvent(
new CustomEvent('miniapp:supportChatMode', {
detail: { active: view === 'chat' },
}),
);
return () => {
window.dispatchEvent(
new CustomEvent('miniapp:supportChatMode', {
detail: { active: false },
}),
);
};
}, [view]);
useEffect(() => {
if (view !== 'chat') return;
const vv = window.visualViewport;
if (!vv) return;
const update = () => setViewportHeight(getViewportHeight());
update();
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
return () => {
vv.removeEventListener('resize', update);
vv.removeEventListener('scroll', update);
};
}, [view]);
if (view === 'chat') {
return (
<div style={{ padding: 24, maxWidth: 560, margin: '0 auto' }}>
<Button
type="text"
icon={<ArrowLeft size={18} />}
onClick={handleBack}
style={{ marginBottom: 16, paddingLeft: 0 }}
>
К списку обращений
</Button>
<SupportChat
claimId={selectedClaimId === null ? undefined : selectedClaimId ?? undefined}
source="bar"
onSuccess={() => {
handleBack();
if (onNavigate) onNavigate('/hello');
else {
window.history.pushState({}, '', '/hello');
window.dispatchEvent(new PopStateEvent('popstate'));
}
}}
/>
<div
style={{
height: viewportHeight,
overflow: 'auto',
WebkitOverflowScrolling: 'touch',
display: 'flex',
flexDirection: 'column',
}}
>
<div style={{ padding: 24, maxWidth: 560, margin: '0 auto', flex: '1 1 auto', minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<Button
type="text"
icon={<ArrowLeft size={18} />}
onClick={handleBack}
style={{ marginBottom: 16, paddingLeft: 0, flexShrink: 0 }}
>
К списку обращений
</Button>
<div style={{ flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }}>
<SupportChat
claimId={selectedClaimId === null ? undefined : selectedClaimId ?? undefined}
source="bar"
onSuccess={() => {
handleBack();
if (onNavigate) onNavigate('/hello');
else {
window.history.pushState({}, '', '/hello');
window.dispatchEvent(new PopStateEvent('popstate'));
}
}}
/>
</div>
</div>
</div>
);
}
@@ -120,19 +181,23 @@ export default function Support({ onNavigate }: SupportProps) {
Ваши обращения и переписка с поддержкой.
</Text>
<Button
type="primary"
icon={<MessageCirclePlus size={18} style={{ marginRight: 6 }} />}
onClick={() => handleOpenThread(null)}
style={{ marginBottom: 24, width: '100%' }}
>
Новое обращение
</Button>
{getSessionToken() && (
<Button
type="primary"
icon={<MessageCirclePlus size={18} style={{ marginRight: 6 }} />}
onClick={() => handleOpenThread(null)}
style={{ marginBottom: 24, width: '100%' }}
>
Новое обращение
</Button>
)}
{loading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin />
</div>
) : !getSessionToken() ? (
<Text type="secondary">Войдите в аккаунт, чтобы видеть обращения и писать в поддержку.</Text>
) : threads.length === 0 ? (
<Text type="secondary">Пока нет обращений. Нажмите «Новое обращение», чтобы написать.</Text>
) : (