💄 feat(ui): Hint-реестр — inline-подсказки первого визита (кадр A1, §6)

Компонент Hint с реестром закрытий в localStorage; первая подсказка —
про видимость диалогов, под вкладками списка, только менеджерам
(сотрудникам подсказки не показываются по §6).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Fable 5 committed 2026-09-05 01:29:11 +03:00
1 parent 09e9819fdd
commit 8c846cc381
5 files changed
+85 -2

No files matched your search

@@ -1,6 +1,7 @@
import { useState, type ReactNode } from "react";
import { ChatMobileHeader } from "../../layout/ChatMobileHeader";
import { Hint } from "../../shared/Hint";
import { ConversationWorkspace, type DialogScope } from "../conversations/ConversationWorkspace";
import { DialogControls } from "../conversations/DialogControls";
@@ -51,6 +52,9 @@ export function ChatPage({
counters={counters}
showScopeSwitcher={showScopeSwitcher}
mobileHeader={<ChatMobileHeader user={user} setRoute={setRoute} onLogout={onLogout} />}
hint={showScopeSwitcher
? <Hint id="chat-visibility">Вы видите все диалоги организации. Сотрудники видят только диалоги своих групп, без группы и те, где они ответственные.</Hint>
: undefined}
renderContextPanel={({ dialog, detail, applyConversation }) => (
<ChatContextPanel
rightTab={rightTab}
@@ -45,12 +45,13 @@ import { useIncomingMessageSound } from "./useIncomingMessageSound";
// Общий workspace диалогов (SPEC-HUB-0010 §8.2). Видимость inbox решает
// backend по группам (ADR-HUB-0043); страница параметризуется заголовком,
// placeholder поиска и правой панелью через render-prop.
export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceholder, renderContextPanel, mobileHeader, hint, initialConversationId, scope, setScope, counters, showScopeSwitcher = true }: {
isOwner?: boolean;
listTitle?: string;
searchPlaceholder?: string;
renderContextPanel: (ctx: { dialog: ConversationListItem | null; detail: ApiConversation | null; applyConversation: (updated: ApiConversation) => void }) => ReactNode;
mobileHeader?: ReactNode;
hint?: ReactNode;
initialConversationId?: number | null;
// Охват (дерево фильтров) живёт в Shell: у сотрудника им управляет сайдбар,
// у менеджера — поповер в заголовке списка.
@@ -198,6 +199,7 @@ export function ConversationWorkspace({ isOwner = false, listTitle, searchPlaceh
setMobileDialogOpen(true);
}}
mobileHeader={mobileHeader}
hint={hint}
/>
<section className="sales-conversation">
<ConversationThread controlMode={controlMode} dialog={selectedDialog} detail={detail} isOwner={isOwner} onClaim={onClaim} onCall={(kind) => void callController.start(kind)} onClose={onClose} onSpam={onSpam} onReturnQueue={onReturnQueue} onArchive={onArchive} onToggleContext={() => setCtxOpen((open) => !open)} onMobileBack={() => setMobileDialogOpen(false)} />
@@ -10,7 +10,7 @@ import type { ConversationCounters } from "./model";
import type { ConversationListItem, ListTab } from "./types";
import { SearchInput } from "../../shared/ui-controls";
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по клиенту, продукту…", scope, counters, setScope, showScopeSwitcher = true, mobileHeader, dialogs, filtered, listTab, selectedId, search, errorText, setSearch, setListTab, setSelectedId }: {
export function DialogList({ title = "Диалоги", searchPlaceholder = "Поиск по клиенту, продукту…", scope, counters, setScope, showScopeSwitcher = true, mobileHeader, hint, dialogs, filtered, listTab, selectedId, search, errorText, setSearch, setListTab, setSelectedId }: {
title?: string;
searchPlaceholder?: string;
scope: DialogScope;
@@ -18,6 +18,7 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
setScope: (scope: DialogScope) => void;
showScopeSwitcher?: boolean;
mobileHeader?: ReactNode;
hint?: ReactNode;
dialogs: ConversationListItem[];
filtered: ConversationListItem[];
listTab: ListTab;
@@ -46,6 +47,7 @@ export function DialogList({ title = "Диалоги", searchPlaceholder = "По
<DialogTab active={listTab === "mine"} onClick={() => setListTab("mine")}>Мои</DialogTab>
<DialogTab active={listTab === "wait"} onClick={() => setListTab("wait")}>Ждут оператора {waitCount > 0 && <b>{waitCount}</b>}</DialogTab>
</div>
{hint}
<div className="sales-dialog-list-body">
{errorText && <div className="sales-wait-note sales-load-error">{errorText}</div>}
{/* Кадр S1: пустой список без призыва к действию. */}
@@ -291,3 +291,40 @@ img.sales-dialog-avatar {
.scope-dropdown .scope-dot.is-muted {
background: var(--n-5);
}
/* Inline-подсказка первого визита (кадр A1). */
.hub-hint {
display: flex;
align-items: flex-start;
gap: 9px;
margin: 10px 12px 2px;
padding: 9px 10px 9px 12px;
border: 1px solid var(--primary-border);
border-radius: 8px;
background: var(--primary-bg);
}
.hub-hint p {
flex: 1;
margin: 0;
color: var(--n-2);
font-size: 12.5px;
line-height: 1.45;
}
.hub-hint button {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
flex: none;
border: 0;
border-radius: 5px;
color: var(--n-4);
background: transparent;
}
.hub-hint button:hover {
background: rgba(0, 0, 0, 0.06);
}
+38
View File
@@ -0,0 +1,38 @@
import { useState, type ReactNode } from "react";
import { Icon } from "./icons";
// Реестр inline-подсказок первого визита (дизайн-базлайн v2, кадр A1; §6 —
// сотрудникам подсказки не показываются, это решает вызывающая сторона).
// Закрытие запоминается на устройстве по ключу `id`.
const STORAGE_PREFIX = "chatbolls.hint.";
function isDismissed(id: string): boolean {
try {
return window.localStorage.getItem(STORAGE_PREFIX + id) === "dismissed";
} catch {
return false;
}
}
export function Hint({ id, children }: { id: string; children: ReactNode }) {
const [dismissed, setDismissed] = useState(() => isDismissed(id));
if (dismissed) return null;
function dismiss() {
setDismissed(true);
try {
window.localStorage.setItem(STORAGE_PREFIX + id, "dismissed");
} catch {
// Подсказка просто вернётся в следующей сессии.
}
}
return (
<div className="hub-hint">
<p>{children}</p>
<button aria-label="Скрыть подсказку" type="button" onClick={dismiss}><Icon name="xCircle" size={13} /></button>
</div>
);
}