diff --git a/docs/README.md b/docs/README.md index 26f01f613..e991bca52 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,8 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView 图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。 +桌面端全站客服悬浮入口的全局接入点、固定层级、拖拽边界和移动端隐藏口径见 [【前端架构】全站客服悬浮入口接入约定-2026-06-23.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%9E%B6%E6%9E%84%E3%80%91%E5%85%A8%E7%AB%99%E5%AE%A2%E6%9C%8D%E6%82%AC%E6%B5%AE%E5%85%A5%E5%8F%A3%E6%8E%A5%E5%85%A5%E7%BA%A6%E5%AE%9A-2026-06-23.md)。 + 桌面端 `/creation` 创作工具主页、顶级“草稿”入口替换为“项目”、最近项目、新建项目和陶泥儿精选素材瀑布流的落地计划见 [【玩法创作】创作主页与项目入口改版计划-2026-06-18.md](./%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E4%B8%BB%E9%A1%B5%E4%B8%8E%E9%A1%B9%E7%9B%AE%E5%85%A5%E5%8F%A3%E6%94%B9%E7%89%88%E8%AE%A1%E5%88%92-2026-06-18.md)。 本地通过 SSH alias 管理多台服务器、查看硬件 / systemd / HTTP 健康状态并执行受控服务启停的 egui 桌面工具见 [【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md](./technical/【开发运维】本地SSH服务器管理面板技术方案-2026-06-11.md)。 diff --git a/docs/technical/【前端架构】全站客服悬浮入口接入约定-2026-06-23.md b/docs/technical/【前端架构】全站客服悬浮入口接入约定-2026-06-23.md new file mode 100644 index 000000000..90064e199 --- /dev/null +++ b/docs/technical/【前端架构】全站客服悬浮入口接入约定-2026-06-23.md @@ -0,0 +1,36 @@ +# 全站客服悬浮入口接入约定 + +日期:2026-06-23 + +## 背景 + +主站需要在桌面端每个页面提供稳定可见的客服入口,包括 `/editor/canvas` 这类全屏画布页面。入口使用陶泥儿客服形象图片,不进入具体业务页布局,避免被滚动、画布缩放或面板层级影响。 + +## 接入口 + +- 全局 React 入口为 `src/components/common/FloatingFeedbackEntry.tsx`。 +- 在 `src/main.tsx` 的 `RouteImageReadyGate` 内、当前路由组件之后渲染,覆盖主站 `resolveAppRoute` 管理的所有页面。 +- 图片资产放在 `public/branding/taonier-feedback-entry.png`,并通过 `BRAND_ASSETS.taonierFeedbackEntry` 引用。 +- 悬浮层样式使用 `.floating-feedback-entry`,通过 `position: fixed` 和最高层级保持在页面最上方。 + +## 交互约定 + +- 仅桌面端展示,媒体查询为 `(min-width: 1024px)`;移动端不渲染入口。 +- 初始位置靠近页面左下角,随窗口滚动保持固定。 +- 用户可以按住拖动,拖拽过程中实时夹在当前 viewport 内,保证图片完整可见,不被窗口边界遮挡。 +- 窗口尺寸变化后重新夹取当前位置,避免缩小窗口后入口跑出视口。 +- 点击入口使用新页面打开客服表单: + +```text +https://kcnz41bksl1c.feishu.cn/share/base/form/shrcn1p9WpuZMDxrI5ysLLJhw7k +``` + +- 拖拽释放后短时间内的补发 click 会被吞掉,避免用户拖动入口时误打开表单。 + +## 验收口径 + +- 桌面端主站任意路由均能看到客服入口,画布页也可见。 +- 滚动页面时入口位置相对视口不变。 +- 拖到窗口四边时入口完整留在可见区域内。 +- 移动端视口不渲染入口。 +- 单击入口新开页面跳转到飞书表单。 diff --git a/public/branding/taonier-feedback-entry.png b/public/branding/taonier-feedback-entry.png new file mode 100644 index 000000000..9699f2918 Binary files /dev/null and b/public/branding/taonier-feedback-entry.png differ diff --git a/public/branding/taonier-feedback-qq.png b/public/branding/taonier-feedback-qq.png new file mode 100644 index 000000000..be160c73a Binary files /dev/null and b/public/branding/taonier-feedback-qq.png differ diff --git a/public/branding/taonier-feedback-wechat.png b/public/branding/taonier-feedback-wechat.png new file mode 100644 index 000000000..4210f0952 Binary files /dev/null and b/public/branding/taonier-feedback-wechat.png differ diff --git a/src/components/common/FloatingFeedbackEntry.test.tsx b/src/components/common/FloatingFeedbackEntry.test.tsx new file mode 100644 index 000000000..2f39ea107 --- /dev/null +++ b/src/components/common/FloatingFeedbackEntry.test.tsx @@ -0,0 +1,193 @@ +/* @vitest-environment jsdom */ + +import { fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, expect, test, vi } from 'vitest'; + +import { FloatingFeedbackEntry } from './FloatingFeedbackEntry'; +import { + clampFloatingFeedbackPosition, + FLOATING_FEEDBACK_FORM_URL, + resolveFloatingFeedbackContactPanelOffset, + resolveInitialFloatingFeedbackPosition, +} from './floatingFeedbackEntryModel'; + +function mockMatchMedia(matches: boolean) { + Object.defineProperty(window, 'matchMedia', { + configurable: true, + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +} + +function mockViewport(width: number, height: number) { + Object.defineProperty(window, 'innerWidth', { + configurable: true, + writable: true, + value: width, + }); + Object.defineProperty(window, 'innerHeight', { + configurable: true, + writable: true, + value: height, + }); +} + +afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); +}); + +test('desktop renders floating feedback entry near lower left', () => { + mockMatchMedia(true); + mockViewport(1440, 900); + + render(); + + const button = screen.getByRole('button', { name: '反馈点我' }); + const shell = button.closest('.floating-feedback-entry-shell') as HTMLElement; + expect(button.className).toContain('floating-feedback-entry'); + expect(shell.style.left).toBe('24px'); + expect(shell.style.top).toBe('746.4px'); + expect(button.querySelector('img')?.getAttribute('src')).toBe( + '/branding/taonier-feedback-entry.png', + ); +}); + +test('hover shows contact QR images and mouse leave hides them', () => { + mockMatchMedia(true); + mockViewport(1440, 900); + + render(); + + const button = screen.getByRole('button', { name: '反馈点我' }); + expect(document.querySelector('.floating-feedback-entry-contact-panel')).toBeNull(); + + fireEvent.pointerEnter(button); + + const panel = document.querySelector('.floating-feedback-entry-contact-panel'); + expect(panel).not.toBeNull(); + expect( + (panel as HTMLElement).style.getPropertyValue( + '--floating-feedback-contact-offset', + ), + ).toBe('141.2px'); + const images = Array.from(panel?.querySelectorAll('img') ?? []); + expect(images.map((image) => image.getAttribute('src'))).toEqual([ + '/branding/taonier-feedback-qq.png', + '/branding/taonier-feedback-wechat.png', + ]); + + fireEvent.pointerLeave(button); + + expect(document.querySelector('.floating-feedback-entry-contact-panel')).toBeNull(); +}); + +test('mobile does not render feedback entry', () => { + mockMatchMedia(false); + mockViewport(390, 780); + + render(); + + expect(screen.queryByRole('button', { name: '反馈点我' })).toBeNull(); +}); + +test('click opens feedback form in a new page', () => { + mockMatchMedia(true); + mockViewport(1440, 900); + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + render(); + + fireEvent.click(screen.getByRole('button', { name: '反馈点我' })); + + expect(openSpy).toHaveBeenCalledWith( + FLOATING_FEEDBACK_FORM_URL, + '_blank', + 'noopener,noreferrer', + ); +}); + +test('hover contact panel does not block click opening feedback form', () => { + mockMatchMedia(true); + mockViewport(1440, 900); + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + render(); + + const button = screen.getByRole('button', { name: '反馈点我' }); + fireEvent.pointerEnter(button); + fireEvent.click(button); + + expect(document.querySelector('.floating-feedback-entry-contact-panel')).not.toBeNull(); + expect(openSpy).toHaveBeenCalledWith( + FLOATING_FEEDBACK_FORM_URL, + '_blank', + 'noopener,noreferrer', + ); +}); + +test('drag keeps entry fully inside the viewport and does not open form', () => { + mockMatchMedia(true); + mockViewport(360, 320); + const openSpy = vi.spyOn(window, 'open').mockReturnValue(null); + + render(); + + const button = screen.getByRole('button', { name: '反馈点我' }); + fireEvent.mouseEnter(button); + expect(document.querySelector('.floating-feedback-entry-contact-panel')).not.toBeNull(); + fireEvent.mouseDown(button, { + button: 0, + clientX: 40, + clientY: 220, + }); + expect(document.querySelector('.floating-feedback-entry-contact-panel')).toBeNull(); + fireEvent.mouseMove(window, { + clientX: 900, + clientY: 900, + }); + fireEvent.mouseUp(window, { + clientX: 900, + clientY: 900, + }); + expect(document.querySelector('.floating-feedback-entry-contact-panel')).not.toBeNull(); + fireEvent.click(button); + + const shell = button.closest('.floating-feedback-entry-shell') as HTMLElement; + expect(shell.style.left).toBe('282.4px'); + expect(shell.style.top).toBe('242.4px'); + expect(openSpy).not.toHaveBeenCalled(); +}); + +test('floating feedback position helpers clamp to visible viewport', () => { + expect( + clampFloatingFeedbackPosition( + { left: -100, top: 1000 }, + { width: 360, height: 320 }, + ), + ).toEqual({ left: 20, top: 242.4 }); + expect( + resolveInitialFloatingFeedbackPosition({ width: 1440, height: 900 }), + ).toEqual({ left: 24, top: 746.4 }); + expect( + resolveFloatingFeedbackContactPanelOffset( + { left: 20, top: 100 }, + { width: 1440 }, + ), + ).toBe(145.2); + expect( + resolveFloatingFeedbackContactPanelOffset( + { left: 20, top: 100 }, + { width: 360 }, + ), + ).toBe(0); +}); diff --git a/src/components/common/FloatingFeedbackEntry.tsx b/src/components/common/FloatingFeedbackEntry.tsx new file mode 100644 index 000000000..84b89e8ec --- /dev/null +++ b/src/components/common/FloatingFeedbackEntry.tsx @@ -0,0 +1,336 @@ +import { + type CSSProperties, + type MouseEvent, + type PointerEvent, + useEffect, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; + +import { BRAND_ASSETS } from '../../uiAssets'; +import { + clampFloatingFeedbackPosition, + FLOATING_FEEDBACK_DESKTOP_QUERY, + FLOATING_FEEDBACK_DRAG_CLICK_SUPPRESS_MS, + FLOATING_FEEDBACK_DRAG_CLICK_THRESHOLD, + FLOATING_FEEDBACK_FORM_URL, + FLOATING_FEEDBACK_INITIAL_LEFT, + FLOATING_FEEDBACK_MARGIN, + type FloatingFeedbackPosition, + resolveFloatingFeedbackContactPanelOffset, + resolveInitialFloatingFeedbackPosition, +} from './floatingFeedbackEntryModel'; + +type FloatingFeedbackDragState = { + pointerId: number; + source: 'mouse' | 'pointer'; + startClientX: number; + startClientY: number; + startLeft: number; + startTop: number; + hasMoved: boolean; +}; + +function isSameFloatingFeedbackPointer( + currentPointerId: number, + nextPointerId: number, +) { + if (!Number.isFinite(currentPointerId) || !Number.isFinite(nextPointerId)) { + return true; + } + + return currentPointerId === nextPointerId; +} + +function shouldUseMouseDragFallback() { + return ( + typeof window !== 'undefined' && + typeof window.PointerEvent !== 'function' + ); +} + +function getViewportSize() { + return { + width: window.innerWidth || document.documentElement.clientWidth, + height: window.innerHeight || document.documentElement.clientHeight, + }; +} + +function getInitialDesktopVisibility() { + if ( + typeof window === 'undefined' || + typeof window.matchMedia !== 'function' + ) { + return false; + } + + return window.matchMedia(FLOATING_FEEDBACK_DESKTOP_QUERY).matches; +} + +export function FloatingFeedbackEntry() { + const [isDesktopVisible, setIsDesktopVisible] = useState( + getInitialDesktopVisibility, + ); + const [isPointerOverEntry, setIsPointerOverEntry] = useState(false); + const [isDragging, setIsDragging] = useState(false); + const [position, setPosition] = useState(() => + typeof window === 'undefined' + ? { left: FLOATING_FEEDBACK_INITIAL_LEFT, top: FLOATING_FEEDBACK_MARGIN } + : resolveInitialFloatingFeedbackPosition(getViewportSize()), + ); + const dragStateRef = useRef(null); + const suppressClickUntilRef = useRef(0); + + useEffect(() => { + if ( + typeof window === 'undefined' || + typeof window.matchMedia !== 'function' + ) { + return; + } + + const mediaQuery = window.matchMedia(FLOATING_FEEDBACK_DESKTOP_QUERY); + const updateVisibility = (event?: MediaQueryListEvent) => { + setIsDesktopVisible(event?.matches ?? mediaQuery.matches); + }; + + updateVisibility(); + + if (typeof mediaQuery.addEventListener === 'function') { + mediaQuery.addEventListener('change', updateVisibility); + return () => mediaQuery.removeEventListener('change', updateVisibility); + } + + mediaQuery.addListener(updateVisibility); + return () => mediaQuery.removeListener(updateVisibility); + }, []); + + useEffect(() => { + const clampToViewport = () => { + setPosition((current) => + clampFloatingFeedbackPosition(current, getViewportSize()), + ); + }; + + window.addEventListener('resize', clampToViewport); + return () => window.removeEventListener('resize', clampToViewport); + }, []); + + const updateDrag = ( + pointerId: number, + source: FloatingFeedbackDragState['source'], + clientX: number, + clientY: number, + ) => { + const dragState = dragStateRef.current; + if ( + !dragState || + dragState.source !== source || + !isSameFloatingFeedbackPointer(dragState.pointerId, pointerId) + ) { + return; + } + + const deltaX = clientX - dragState.startClientX; + const deltaY = clientY - dragState.startClientY; + if ( + !dragState.hasMoved && + Math.hypot(deltaX, deltaY) >= FLOATING_FEEDBACK_DRAG_CLICK_THRESHOLD + ) { + dragState.hasMoved = true; + } + + setPosition( + clampFloatingFeedbackPosition( + { + left: dragState.startLeft + deltaX, + top: dragState.startTop + deltaY, + }, + getViewportSize(), + ), + ); + }; + + const stopDragById = ( + pointerId: number, + source: FloatingFeedbackDragState['source'], + ) => { + const dragState = dragStateRef.current; + if ( + !dragState || + dragState.source !== source || + !isSameFloatingFeedbackPointer(dragState.pointerId, pointerId) + ) { + return; + } + + suppressClickUntilRef.current = dragState.hasMoved + ? Date.now() + FLOATING_FEEDBACK_DRAG_CLICK_SUPPRESS_MS + : 0; + dragStateRef.current = null; + setIsDragging(false); + }; + + useEffect(() => { + const handlePointerMove = (event: globalThis.PointerEvent) => { + const dragState = dragStateRef.current; + if ( + !dragState || + !isSameFloatingFeedbackPointer(dragState.pointerId, event.pointerId) + ) { + return; + } + + updateDrag(event.pointerId, 'pointer', event.clientX, event.clientY); + }; + + const stopPointerDrag = (event: globalThis.PointerEvent) => { + stopDragById(event.pointerId, 'pointer'); + }; + + window.addEventListener('pointermove', handlePointerMove); + window.addEventListener('pointerup', stopPointerDrag); + window.addEventListener('pointercancel', stopPointerDrag); + return () => { + window.removeEventListener('pointermove', handlePointerMove); + window.removeEventListener('pointerup', stopPointerDrag); + window.removeEventListener('pointercancel', stopPointerDrag); + }; + }, []); + + useEffect(() => { + const handleMouseMove = (event: globalThis.MouseEvent) => { + const dragState = dragStateRef.current; + if (!dragState || dragState.source !== 'mouse') { + return; + } + + updateDrag(Number.NaN, 'mouse', event.clientX, event.clientY); + }; + + const stopMouseDrag = () => { + stopDragById(Number.NaN, 'mouse'); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', stopMouseDrag); + return () => { + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', stopMouseDrag); + }; + }, []); + + const startPointerDrag = (event: PointerEvent) => { + if (event.button > 0) { + return; + } + + event.currentTarget.setPointerCapture?.(event.pointerId); + setIsDragging(true); + dragStateRef.current = { + pointerId: event.pointerId, + source: 'pointer', + startClientX: event.clientX, + startClientY: event.clientY, + startLeft: position.left, + startTop: position.top, + hasMoved: false, + }; + }; + + const startMouseDrag = (event: MouseEvent) => { + if (!shouldUseMouseDragFallback() || event.button > 0) { + return; + } + + setIsDragging(true); + dragStateRef.current = { + pointerId: Number.NaN, + source: 'mouse', + startClientX: event.clientX, + startClientY: event.clientY, + startLeft: position.left, + startTop: position.top, + hasMoved: false, + }; + }; + + const openFeedbackForm = () => { + if (suppressClickUntilRef.current >= Date.now()) { + suppressClickUntilRef.current = 0; + dragStateRef.current = null; + return; + } + + window.open(FLOATING_FEEDBACK_FORM_URL, '_blank', 'noopener,noreferrer'); + }; + + if (!isDesktopVisible || typeof document === 'undefined') { + return null; + } + + const shouldShowContactPanel = isPointerOverEntry && !isDragging; + const contactPanelOffset = resolveFloatingFeedbackContactPanelOffset( + position, + getViewportSize(), + ); + + return createPortal( +
+ {shouldShowContactPanel ? ( + + ) : null} + +
, + document.body, + ); +} diff --git a/src/components/common/floatingFeedbackEntryModel.ts b/src/components/common/floatingFeedbackEntryModel.ts new file mode 100644 index 000000000..a45100f8d --- /dev/null +++ b/src/components/common/floatingFeedbackEntryModel.ts @@ -0,0 +1,81 @@ +export const FLOATING_FEEDBACK_FORM_URL = + 'https://kcnz41bksl1c.feishu.cn/share/base/form/shrcn1p9WpuZMDxrI5ysLLJhw7k'; + +export const FLOATING_FEEDBACK_DESKTOP_QUERY = '(min-width: 1024px)'; +export const FLOATING_FEEDBACK_SIZE = 57.6; +export const FLOATING_FEEDBACK_MARGIN = 20; +export const FLOATING_FEEDBACK_INITIAL_LEFT = 24; +export const FLOATING_FEEDBACK_INITIAL_BOTTOM = 96; +export const FLOATING_FEEDBACK_DRAG_CLICK_THRESHOLD = 4; +export const FLOATING_FEEDBACK_DRAG_CLICK_SUPPRESS_MS = 250; +export const FLOATING_FEEDBACK_CONTACT_PANEL_WIDTH = 348; + +export type FloatingFeedbackPosition = { + left: number; + top: number; +}; + +function roundFloatingFeedbackCoordinate(value: number) { + return Math.round(value * 10) / 10; +} + +export function clampFloatingFeedbackPosition( + position: FloatingFeedbackPosition, + viewport: { width: number; height: number }, +) { + const minLeft = FLOATING_FEEDBACK_MARGIN; + const minTop = FLOATING_FEEDBACK_MARGIN; + const maxLeft = Math.max( + minLeft, + viewport.width - FLOATING_FEEDBACK_SIZE - FLOATING_FEEDBACK_MARGIN, + ); + const maxTop = Math.max( + minTop, + viewport.height - FLOATING_FEEDBACK_SIZE - FLOATING_FEEDBACK_MARGIN, + ); + + return { + left: roundFloatingFeedbackCoordinate( + Math.min(maxLeft, Math.max(minLeft, position.left)), + ), + top: roundFloatingFeedbackCoordinate( + Math.min(maxTop, Math.max(minTop, position.top)), + ), + }; +} + +export function resolveInitialFloatingFeedbackPosition(viewport: { + width: number; + height: number; +}) { + return clampFloatingFeedbackPosition( + { + left: FLOATING_FEEDBACK_INITIAL_LEFT, + top: + viewport.height - + FLOATING_FEEDBACK_SIZE - + FLOATING_FEEDBACK_INITIAL_BOTTOM, + }, + viewport, + ); +} + +export function resolveFloatingFeedbackContactPanelOffset( + position: FloatingFeedbackPosition, + viewport: { width: number }, +) { + const entryCenter = position.left + FLOATING_FEEDBACK_SIZE / 2; + const minCenter = FLOATING_FEEDBACK_MARGIN + FLOATING_FEEDBACK_CONTACT_PANEL_WIDTH / 2; + const maxCenter = + viewport.width - + FLOATING_FEEDBACK_MARGIN - + FLOATING_FEEDBACK_CONTACT_PANEL_WIDTH / 2; + + if (maxCenter < minCenter) { + return 0; + } + + return roundFloatingFeedbackCoordinate( + Math.min(maxCenter, Math.max(minCenter, entryCenter)) - entryCenter, + ); +} diff --git a/src/index.css b/src/index.css index 283343c15..d1a7c607a 100644 --- a/src/index.css +++ b/src/index.css @@ -1562,6 +1562,75 @@ html[data-mobile-keyboard-open='true'] #root { color: var(--platform-brand-logo-subtitle); } +.floating-feedback-entry-shell { + position: fixed; + z-index: 2147483647; + width: 57.6px; + height: 57.6px; +} + +.floating-feedback-entry-contact-panel { + position: absolute; + left: 50%; + bottom: calc(100% + 12px); + display: flex; + box-sizing: border-box; + width: 348px; + gap: 12px; + align-items: center; + justify-content: center; + padding: 10px; + border: 1px solid rgba(214, 154, 111, 0.24); + border-radius: 12px; + background: rgba(255, 252, 248, 0.97); + box-shadow: 0 18px 36px rgba(74, 34, 15, 0.18); + pointer-events: none; + transform: translateX(calc(-50% + var(--floating-feedback-contact-offset, 0px))); +} + +.floating-feedback-entry-contact-panel img { + display: block; + width: 156px; + height: 156px; + border-radius: 8px; + object-fit: contain; + user-select: none; +} + +.floating-feedback-entry { + position: relative; + display: inline-flex; + width: 57.6px; + height: 57.6px; + align-items: center; + justify-content: center; + border: 0; + border-radius: 999px; + background: transparent; + padding: 0; + cursor: grab; + touch-action: none; + user-select: none; + filter: drop-shadow(0 0.7rem 1.05rem rgba(98, 48, 24, 0.24)); +} + +.floating-feedback-entry:active { + cursor: grabbing; +} + +.floating-feedback-entry:focus-visible { + outline: 3px solid rgba(204, 117, 76, 0.72); + outline-offset: 4px; +} + +.floating-feedback-entry img { + display: block; + width: 100%; + height: 100%; + pointer-events: none; + user-select: none; +} + .platform-main-shell { position: relative; width: 100%; diff --git a/src/main.tsx b/src/main.tsx index 17fd6ac8e..c730f9992 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import './index.css'; import {StrictMode, Suspense} from 'react'; import {createRoot} from 'react-dom/client'; +import {FloatingFeedbackEntry} from './components/common/FloatingFeedbackEntry'; import {stabilizeMobileViewportKeyboardFocus} from './mobileViewportKeyboardFocus'; import {lockMobileViewportZoom} from './mobileViewportZoomLock'; import {resolveAppRoute} from './routing/appRoutes'; @@ -54,5 +55,6 @@ root.render( + , ); diff --git a/src/uiAssets.ts b/src/uiAssets.ts index eca2ada6d..2b2267291 100644 --- a/src/uiAssets.ts +++ b/src/uiAssets.ts @@ -54,6 +54,9 @@ export const CUSTOM_WORLD_THEME_ICONS = { export const BRAND_ASSETS = { kuranGamesLogo: '/branding/kuran-games-logo.svg', taonierProductIp: '/branding/taonier-product-ip.png', + taonierFeedbackEntry: '/branding/taonier-feedback-entry.png', + taonierFeedbackQq: '/branding/taonier-feedback-qq.png', + taonierFeedbackWechat: '/branding/taonier-feedback-wechat.png', } as const; export const UI_CHROME = {