mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
💄 feat(ui): Hint-реестр — inline-подсказки первого визита (кадр A1, §6)
Компонент Hint с реестром закрытий в localStorage; первая подсказка — про видимость диалогов, под вкладками списка, только менеджерам (сотрудникам подсказки не показываются по §6). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user