From e8c36e216933d26b84f8c0580f6b1719146f868d Mon Sep 17 00:00:00 2001 From: Girts Date: Wed, 8 Jul 2026 14:01:02 +0300 Subject: [PATCH 01/14] add HelpScout Beacon integration Embed the official Beacon loader snippet as a nonce-carrying inline script (bodyClose) in nuxt.config.ts and init it with our Beacon ID. Allow the required HelpScout origins in the CSP (connect/style/font/ frame/media-src) per their documented requirements; object-src stays 'none'. Add a regression test locking the connect-src entries. Co-Authored-By: Claude Fable 5 --- nuxt.config.ts | 10 ++++++++++ server/plugins/csp.ts | 17 +++++++++++++---- tests/server/security.test.ts | 9 +++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index ae1adb6cd..5cd7f912d 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -4,6 +4,11 @@ import { lstatSync } from 'node:fs' import { resolve } from 'node:path' const themeBootstrapScript = '(function(){var theme="dark";try{theme=localStorage.getItem("theme")==="light"?"light":"dark"}catch(e){}document.documentElement.setAttribute("data-theme",theme);document.documentElement.style.colorScheme=theme})()' +// HelpScout Beacon loader (official embed snippet, minified) + init call. +// Defers the actual https://beacon-v2.helpscout.net download until window load. +// Executes under CSP via the per-request nonce injected by server/plugins/csp.ts; +// 'strict-dynamic' then trusts the beacon script it inserts. +const helpScoutBeaconScript = '!function(e,t,n){function a(){var e=t.getElementsByTagName("script")[0],n=t.createElement("script");n.type="text/javascript",n.async=!0,n.src="https://beacon-v2.helpscout.net",e.parentNode.insertBefore(n,e)}if(e.Beacon=n=function(t,n,a){e.Beacon.readyQueue.push({method:t,options:n,data:a})},n.readyQueue=[],"complete"===t.readyState)return a();e.attachEvent?e.attachEvent("onload",a):e.addEventListener("load",a,!1)}(window,document,window.Beacon||function(){});window.Beacon("init","29adfc12-af7e-46bc-8bfa-c3eb13225889")' const eulerSdkPackage = '@eulerxyz/euler-v2-sdk' const isLinkedEulerSdk = (() => { try { @@ -53,6 +58,11 @@ export default defineNuxtConfig({ tagPosition: 'head', tagPriority: 'critical', }, + { + id: 'helpscout-beacon', + innerHTML: helpScoutBeaconScript, + tagPosition: 'bodyClose', + }, ], meta: [ { diff --git a/server/plugins/csp.ts b/server/plugins/csp.ts index 0961be594..065925b6f 100644 --- a/server/plugins/csp.ts +++ b/server/plugins/csp.ts @@ -160,6 +160,12 @@ const CONNECT_SRC_BASE = [ 'wss://www.walletlink.org', 'wss://relay.walletconnect.com', 'wss://relay.walletconnect.org', + // HelpScout Beacon (https://docs.helpscout.com/article/815-csp-settings-for-beacon) + 'https://beaconapi.helpscout.net', + 'https://chatapi.helpscout.net', + 'https://d3hb14vkzrxvla.cloudfront.net', + 'https://sockjs-helpscout.pusher.com', + 'wss://*.pusher.com', ] export function buildCsp( @@ -179,18 +185,21 @@ export function buildCsp( const directives = [ 'default-src \'self\'', `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval' https://static.cloudflareinsights.com`, - 'style-src \'unsafe-inline\' \'self\'', + // beacon-v2.helpscout.net: HelpScout Beacon loads its stylesheet, fonts, + // notification sounds, and chat iframe from there. object-src stays 'none' — + // the Beacon iframe is covered by frame-src. + 'style-src \'unsafe-inline\' \'self\' https://beacon-v2.helpscout.net https://fonts.googleapis.com', 'object-src \'none\'', 'base-uri \'self\'', `connect-src ${connectSrc.join(' ')}`, - 'font-src \'self\' https://fonts.reown.com', - 'frame-src \'self\' https://verify.walletconnect.org https://verify.walletconnect.com', + 'font-src \'self\' data: https://fonts.reown.com https://fonts.gstatic.com https://beacon-v2.helpscout.net', + 'frame-src \'self\' https://verify.walletconnect.org https://verify.walletconnect.com https://beacon-v2.helpscout.net', 'frame-ancestors \'none\'', // Token logos come from arbitrary CDNs (CoinGecko, DefiLlama, Uniswap, etc.) // that cannot be whitelisted upfront. Images are passive content — no script execution risk. 'img-src \'self\' data: blob: https:', 'manifest-src \'self\'', - 'media-src \'self\'', + 'media-src \'self\' https://beacon-v2.helpscout.net', 'worker-src \'self\' blob:', 'form-action \'self\'', ...(isDev ? [] : ['upgrade-insecure-requests']), diff --git a/tests/server/security.test.ts b/tests/server/security.test.ts index 46aafa7f9..e7e560635 100644 --- a/tests/server/security.test.ts +++ b/tests/server/security.test.ts @@ -66,6 +66,15 @@ describe('buildCsp', () => { .find(d => d.startsWith('connect-src')) expect(connectSrc).toContain('https://api.4byte.sourcify.dev') }) + + it('allows HelpScout Beacon API and chat traffic', () => { + const connectSrc = csp + .split(';') + .map(d => d.trim()) + .find(d => d.startsWith('connect-src')) + expect(connectSrc).toContain('https://beaconapi.helpscout.net') + expect(connectSrc).toContain('https://chatapi.helpscout.net') + }) }) describe('applySecurityHeaders', () => { From 121a8ef6c41e104055f8122ccfa3911788c7b483 Mon Sep 17 00:00:00 2001 From: Girts Date: Wed, 8 Jul 2026 14:24:22 +0300 Subject: [PATCH 02/14] hide HelpScout Beacon on the onboarding page The Beacon container mounts asynchronously after window load, so a route watcher in app.vue toggles a beacon-hidden root class instead of touching the element, and main.scss hides the container under it. Co-Authored-By: Claude Fable 5 --- app.vue | 9 +++++++++ assets/styles/main.scss | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/app.vue b/app.vue index 74748e1f0..d14f59ea9 100644 --- a/app.vue +++ b/app.vue @@ -115,6 +115,15 @@ watch(route, () => { }) }, { immediate: true }) +// Keep the HelpScout Beacon launcher off the onboarding (connect wallet) +// screen. The Beacon container mounts asynchronously after window load, so +// visibility is toggled via a root class (styled in assets/styles/main.scss) +// rather than on the container element itself. +watch(() => route.name, (name) => { + if (!import.meta.client) return + document.documentElement.classList.toggle('beacon-hidden', name === 'onboarding') +}, { immediate: true }) + const checkBatchAnnouncement = () => { if (!enableBatchAnnouncement || batchAnnouncementSeen.value) return if (isBatchAnnouncementOpen || route.name === 'onboarding') return diff --git a/assets/styles/main.scss b/assets/styles/main.scss index 0c13165b9..bf138549a 100644 --- a/assets/styles/main.scss +++ b/assets/styles/main.scss @@ -69,3 +69,10 @@ body { .auto-link strong { font-weight: 600; } + +// HelpScout Beacon is injected asynchronously after window load (see the +// helpscout-beacon head script in nuxt.config.ts), so per-route visibility +// is driven by this root class (toggled in app.vue) instead of the element. +html.beacon-hidden #beacon-container { + display: none; +} From e5095bd7d39b34924ee0844636dd6720f06452e2 Mon Sep 17 00:00:00 2001 From: Girts Date: Tue, 14 Jul 2026 09:42:19 +0300 Subject: [PATCH 03/14] attach connected wallet address to HelpScout conversations Watch the wagmi address and push it via Beacon('session-data') so support agents see the wallet on every conversation without the user typing it. The Beacon embed shim queues calls made before the script loads, so the immediate watcher run is safe. Co-Authored-By: Claude Fable 5 --- app.vue | 10 ++++++++++ types/index.ts | 3 +++ 2 files changed, 13 insertions(+) diff --git a/app.vue b/app.vue index d14f59ea9..8aa27dd2d 100644 --- a/app.vue +++ b/app.vue @@ -124,6 +124,16 @@ watch(() => route.name, (name) => { document.documentElement.classList.toggle('beacon-hidden', name === 'onboarding') }, { immediate: true }) +// Attach the connected wallet address to HelpScout conversations so support +// agents see it without the user typing it. session-data is added to the +// conversation as a visitor activity note when the user submits a message; +// it is not shown in (and cannot be edited via) the Beacon form itself. +// Safe to call before the Beacon script loads — the shim queues calls. +watch(address, (addr) => { + if (!import.meta.client || typeof window.Beacon !== 'function') return + window.Beacon('session-data', { 'Wallet address': addr ?? 'Not connected' }) +}, { immediate: true }) + const checkBatchAnnouncement = () => { if (!enableBatchAnnouncement || batchAnnouncementSeen.value) return if (isBatchAnnouncementOpen || route.name === 'onboarding') return diff --git a/types/index.ts b/types/index.ts index 966fc44f7..9ff8676bb 100644 --- a/types/index.ts +++ b/types/index.ts @@ -4,5 +4,8 @@ declare global { interface Window { pw: unknown gtag: (...args: unknown[]) => void + // HelpScout Beacon JS API. The shim in nuxt.config.ts defines this before + // the app mounts and queues calls until the real Beacon script loads. + Beacon: (method: string, options?: unknown, data?: unknown) => void } } From 7d8627a94e43670abfaaa87b4c8bde13e55d578a Mon Sep 17 00:00:00 2001 From: Girts Date: Tue, 14 Jul 2026 09:47:15 +0300 Subject: [PATCH 04/14] show wallet-attachment notice in the HelpScout message form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beacon has no read-only form fields, so surface the attachment to the user via the responseTime sublabel in the form header — visible but not editable. Updated dynamically alongside session-data whenever the connected address changes. Co-Authored-By: Claude Fable 5 --- app.vue | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app.vue b/app.vue index 8aa27dd2d..792b3967a 100644 --- a/app.vue +++ b/app.vue @@ -126,12 +126,21 @@ watch(() => route.name, (name) => { // Attach the connected wallet address to HelpScout conversations so support // agents see it without the user typing it. session-data is added to the -// conversation as a visitor activity note when the user submits a message; -// it is not shown in (and cannot be edited via) the Beacon form itself. +// conversation as a visitor activity note when the user submits a message. +// The form itself has no read-only fields, so the user is told about the +// attachment via the responseTime sublabel shown in the form header — static +// text they can see but not edit. // Safe to call before the Beacon script loads — the shim queues calls. watch(address, (addr) => { if (!import.meta.client || typeof window.Beacon !== 'function') return window.Beacon('session-data', { 'Wallet address': addr ?? 'Not connected' }) + window.Beacon('config', { + labels: { + responseTime: addr + ? `We usually respond in a few hours. Your connected wallet ${shortenAddress(addr)} will be attached to your message.` + : 'We usually respond in a few hours', + }, + }) }, { immediate: true }) const checkBatchAnnouncement = () => { From a75a06f71980e72af43bacbe271346a03b922b04 Mon Sep 17 00:00:00 2001 From: Girts Date: Tue, 14 Jul 2026 13:10:39 +0300 Subject: [PATCH 05/14] attach console output and app state to HelpScout conversations A ring buffer (installed at boot by 00.console-capture.client.ts) keeps the last 100 console entries with query-string values redacted so URL-borne API keys never reach a ticket. When the user opens the Beacon, the recent output and an app-state snapshot (url, route, chain, wallet, theme, viewport, UA) are attached via session-data, merged with the wallet entry so calls don't clobber each other. The form-header notice now mentions the diagnostics attachment. Co-Authored-By: Claude Fable 5 --- app.vue | 50 ++++++++++++---- plugins/00.console-capture.client.ts | 8 +++ tests/utils/console-capture.test.ts | 51 +++++++++++++++++ utils/console-capture.ts | 86 ++++++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 plugins/00.console-capture.client.ts create mode 100644 tests/utils/console-capture.test.ts create mode 100644 utils/console-capture.ts diff --git a/app.vue b/app.vue index 792b3967a..2288d678e 100644 --- a/app.vue +++ b/app.vue @@ -124,25 +124,55 @@ watch(() => route.name, (name) => { document.documentElement.classList.toggle('beacon-hidden', name === 'onboarding') }, { immediate: true }) -// Attach the connected wallet address to HelpScout conversations so support -// agents see it without the user typing it. session-data is added to the -// conversation as a visitor activity note when the user submits a message. -// The form itself has no read-only fields, so the user is told about the -// attachment via the responseTime sublabel shown in the form header — static -// text they can see but not edit. +// Attach support diagnostics to HelpScout conversations: the connected wallet +// address, recent console output (utils/console-capture.ts), and an app-state +// snapshot. session-data lands in the conversation's visitor activity note +// when the user submits a message. Keys are accumulated locally and always +// sent together so a later call can't clobber earlier entries. +// The form has no read-only fields, so the user is told about the attachment +// via the responseTime sublabel in the form header — visible but not editable. // Safe to call before the Beacon script loads — the shim queues calls. +const beaconSessionData: Record = {} +const setBeaconSessionData = (data: Record) => { + if (typeof window.Beacon !== 'function') return + Object.assign(beaconSessionData, data) + window.Beacon('session-data', { ...beaconSessionData }) +} + watch(address, (addr) => { - if (!import.meta.client || typeof window.Beacon !== 'function') return - window.Beacon('session-data', { 'Wallet address': addr ?? 'Not connected' }) + if (!import.meta.client) return + setBeaconSessionData({ 'Wallet address': addr ?? 'Not connected' }) window.Beacon('config', { labels: { responseTime: addr - ? `We usually respond in a few hours. Your connected wallet ${shortenAddress(addr)} will be attached to your message.` - : 'We usually respond in a few hours', + ? `We usually respond in a few hours. Your connected wallet ${shortenAddress(addr)} and technical diagnostics will be attached to your message.` + : 'We usually respond in a few hours. Technical diagnostics will be attached to your message.', }, }) }, { immediate: true }) +// Diagnostics are snapshotted when the widget opens (not at submit time) — +// Beacon has no pre-submit hook, and open-time state is what prompted the +// user to reach out. +onMounted(() => { + if (typeof window.Beacon !== 'function') return + window.Beacon('on', 'open', () => { + setBeaconSessionData({ + 'Recent console output': getRecentConsoleOutput() || 'none captured', + 'App state': JSON.stringify({ + url: window.location.href, + route: route.name, + chainId: chainId.value, + wallet: address.value ?? 'not connected', + theme: theme.value, + viewport: `${window.innerWidth}x${window.innerHeight}`, + userAgent: navigator.userAgent, + openedAt: new Date().toISOString(), + }), + }) + }) +}) + const checkBatchAnnouncement = () => { if (!enableBatchAnnouncement || batchAnnouncementSeen.value) return if (isBatchAnnouncementOpen || route.name === 'onboarding') return diff --git a/plugins/00.console-capture.client.ts b/plugins/00.console-capture.client.ts new file mode 100644 index 000000000..33b2246b2 --- /dev/null +++ b/plugins/00.console-capture.client.ts @@ -0,0 +1,8 @@ +/** + * Installs the console ring buffer as early as possible (00. prefix sorts + * before the wagmi plugin) so boot-time warnings/errors are captured for + * the HelpScout support diagnostics. See utils/console-capture.ts. + */ +export default defineNuxtPlugin(() => { + installConsoleCapture() +}) diff --git a/tests/utils/console-capture.test.ts b/tests/utils/console-capture.test.ts new file mode 100644 index 000000000..7c8e4ee70 --- /dev/null +++ b/tests/utils/console-capture.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { installConsoleCapture, getRecentConsoleOutput } from '~/utils/console-capture' + +describe('console-capture', () => { + beforeAll(() => { + installConsoleCapture() + }) + + it('captures console output with level and timestamp', () => { + console.log('hello capture') + const output = getRecentConsoleOutput() + expect(output).toMatch(/\[log\] hello capture/) + expect(output).toMatch(/\d{4}-\d{2}-\d{2}T/) + }) + + it('serializes non-string arguments', () => { + console.warn('state', { chainId: 1, ok: true }) + expect(getRecentConsoleOutput()).toContain('[warn] state {"chainId":1,"ok":true}') + }) + + it('redacts query-string values so URL-borne API keys never reach a ticket', () => { + console.error('rpc failed https://eth-mainnet.alchemy.com/v2/data?apiKey=supersecret123&x=1') + const output = getRecentConsoleOutput() + expect(output).not.toContain('supersecret123') + expect(output).toContain('?apiKey=[redacted]') + }) + + it('survives circular structures without throwing', () => { + const circular: Record = {} + circular.self = circular + expect(() => console.log(circular)).not.toThrow() + expect(getRecentConsoleOutput()).toContain('[object Object]') + }) + + it('truncates individual entries', () => { + console.log('x'.repeat(5000)) + const line = getRecentConsoleOutput().split('\n').find(l => l.includes('xxx')) + expect(line).toBeDefined() + expect(line!.length).toBeLessThan(500) + }) + + it('caps total output below the HelpScout session-data value limit', () => { + for (let i = 0; i < 200; i++) { + console.log(`filler line ${i} ${'y'.repeat(300)}`) + } + const output = getRecentConsoleOutput() + expect(output.length).toBeLessThanOrEqual(9000) + // newest entries win — the last line logged must be present + expect(output).toContain('filler line 199') + }) +}) diff --git a/utils/console-capture.ts b/utils/console-capture.ts new file mode 100644 index 000000000..ff527e372 --- /dev/null +++ b/utils/console-capture.ts @@ -0,0 +1,86 @@ +/** + * Ring buffer of recent console output, attached to HelpScout support + * conversations as diagnostics (see the Beacon wiring in app.vue). + * + * Capture starts when plugins/00.console-capture.client.ts installs the + * wrapper at app boot. Entries are truncated and query-string values are + * redacted so RPC API keys or other URL-borne secrets never reach the + * support ticket. + */ + +interface CapturedEntry { + time: string + level: string + text: string +} + +const MAX_ENTRIES = 100 +const MAX_ENTRY_LENGTH = 400 + +const buffer: CapturedEntry[] = [] +let installed = false + +/** Mask query-string values, e.g. "?apiKey=abc123" → "?apiKey=[redacted]". */ +const QUERY_VALUE_RE = /([?&][\w-]+=)[^&\s"']+/g + +const stringifyArg = (arg: unknown): string => { + if (typeof arg === 'string') return arg + if (arg instanceof Error) return `${arg.name}: ${arg.message}` + try { + return JSON.stringify(arg) ?? String(arg) + } + catch { + return String(arg) + } +} + +const push = (level: string, args: unknown[]) => { + const text = args + .map(stringifyArg) + .join(' ') + .replace(QUERY_VALUE_RE, '$1[redacted]') + .slice(0, MAX_ENTRY_LENGTH) + buffer.push({ time: new Date().toISOString(), level, text }) + if (buffer.length > MAX_ENTRIES) buffer.shift() +} + +const CAPTURED_LEVELS = ['log', 'info', 'warn', 'error', 'debug'] as const + +export const installConsoleCapture = () => { + if (installed) return + installed = true + + for (const level of CAPTURED_LEVELS) { + const original = console[level].bind(console) + console[level] = (...args: unknown[]) => { + push(level, args) + original(...args) + } + } + + if (typeof window !== 'undefined') { + window.addEventListener('error', (event) => { + push('uncaught', [event.message]) + }) + window.addEventListener('unhandledrejection', (event) => { + push('unhandledrejection', [event.reason]) + }) + } +} + +/** + * Most recent console output as newline-separated lines, oldest first, + * capped to fit a HelpScout session-data value (10k char limit per entry). + */ +export const getRecentConsoleOutput = (maxChars = 9000): string => { + const lines: string[] = [] + let total = 0 + for (let i = buffer.length - 1; i >= 0; i--) { + const entry = buffer[i] + const line = `${entry.time} [${entry.level}] ${entry.text}` + if (total + line.length + 1 > maxChars) break + lines.unshift(line) + total += line.length + 1 + } + return lines.join('\n') +} From 8942a7140f3627610b0506855ec5643b4433c847 Mon Sep 17 00:00:00 2001 From: Girts Date: Thu, 30 Jul 2026 14:50:39 +0300 Subject: [PATCH 06/14] add in-app support panel behind an opt-in flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Claude Design "Support Window" spec: a support panel built from the app's own components, tokens and theme, opened from Support in the header Resources menu. HelpScout stays the backend (Docs API for search, Mailbox API v2 for the ticket) but every call is server-side via /api/internal/support/*, so nothing third-party runs on the page and no CSP entries are needed. Ships browse + article + compose + confirmation. Reading past conversations is deliberately left out: HelpScout keys conversations by customer email, so that view needs an email-authorized read endpoint which would let anyone knowing an address read that user's history. Off by default (configEnableSupportPanel) because the HELPSCOUT_* server credentials do not exist in any environment yet — the Beacon loader and its CSP entries are untouched so today's support channel keeps working. When the flag is on, the Beacon launcher is hidden so there is a single support surface. Fixes found while adapting the reference implementation: composables resolved during setup instead of inside the send handler (would throw "nuxt instance unavailable"), UiAlert given its required title prop instead of slot content (error was invisible), and h-112 replaced with an arbitrary value since the spacing scale stops at 100. Co-Authored-By: Claude Fable 5 --- app.vue | 10 +- assets/styles/main.scss | 5 +- components/layout/TheHeader.vue | 16 +- components/support/SupportPanel.vue | 415 ++++++++++++++++++ components/support/SupportPanelHost.vue | 46 ++ composables/useDeployConfig.ts | 4 + composables/useSupportPanel.ts | 159 +++++++ docs/support-panel.md | 83 ++++ nuxt.config.ts | 3 + .../internal/support/conversations.post.ts | 108 +++++ server/api/internal/support/docs.get.ts | 63 +++ server/utils/helpscout.ts | 152 +++++++ tailwind.config.js | 1 + tests/server/helpscout-support.test.ts | 90 ++++ 14 files changed, 1151 insertions(+), 4 deletions(-) create mode 100644 components/support/SupportPanel.vue create mode 100644 components/support/SupportPanelHost.vue create mode 100644 composables/useSupportPanel.ts create mode 100644 docs/support-panel.md create mode 100644 server/api/internal/support/conversations.post.ts create mode 100644 server/api/internal/support/docs.get.ts create mode 100644 server/utils/helpscout.ts create mode 100644 tests/server/helpscout-support.test.ts diff --git a/app.vue b/app.vue index f909d8026..39553758d 100644 --- a/app.vue +++ b/app.vue @@ -5,7 +5,7 @@ import { useModal } from '~/components/ui/composables/useModal' const route = useRoute() const router = useRouter() -const { announcement } = useDeployConfig() +const { announcement, enableSupportPanel } = useDeployConfig() const isOnboardingCompleted = useLocalStorage('is-onboarding-completed', false) const announcementSeenToken = useLocalStorage('announcement-seen-token', '') const modal = useModal() @@ -68,6 +68,13 @@ useHead({ ], }) +// With the in-app support panel enabled, suppress the HelpScout Beacon launcher +// so the app has a single support surface. The rule lives in +// assets/styles/main.scss because Beacon injects its container after window load. +if (import.meta.client && enableSupportPanel) { + document.documentElement.classList.add('beacon-hidden') +} + const isMenuVisible = ref(true) const isHeaderVisible = ref(true) let interval: NodeJS.Timeout | null = null @@ -226,6 +233,7 @@ onUnmounted(() => { + diff --git a/assets/styles/main.scss b/assets/styles/main.scss index bf138549a..a184cf403 100644 --- a/assets/styles/main.scss +++ b/assets/styles/main.scss @@ -71,8 +71,9 @@ body { } // HelpScout Beacon is injected asynchronously after window load (see the -// helpscout-beacon head script in nuxt.config.ts), so per-route visibility -// is driven by this root class (toggled in app.vue) instead of the element. +// helpscout-beacon head script in nuxt.config.ts), so its launcher is hidden by +// this root class — set in app.vue when the in-app support panel is enabled — +// rather than on the element itself. html.beacon-hidden #beacon-container { display: none; } diff --git a/components/layout/TheHeader.vue b/components/layout/TheHeader.vue index 11370deaf..b8cb0051e 100644 --- a/components/layout/TheHeader.vue +++ b/components/layout/TheHeader.vue @@ -37,6 +37,7 @@ const { enableExplorePage, enablePoweredByEuler, enableAppTitle, + enableSupportPanel, } = useDeployConfig() const menuItems = getMenuItems( enableEarnPage, @@ -95,6 +96,11 @@ const onSettingsClick = () => { const onLogoClick = () => { isSocialsTooltipVisible.value = !isSocialsTooltipVisible.value } +const support = useSupportPanel() +const onSupportClick = () => { + isSocialsTooltipVisible.value = false + support.open() +} const getIsMenuItemActive = (link: MenuItem) => { return route.name?.toString().startsWith(link.name) } @@ -152,13 +158,21 @@ onClickOutside(wrapperRef, () => { >

Resources

+ + +import { useDebounceFn } from '@vueuse/core' + +const { + view, + query, + email, + article, + lastReference, + results, + isSearching, + isSending, + error, + close, + goHome, + searchDocs, + openArticle, + send, +} = useSupportPanel() + +const { discordUrl, telegramUrl } = useDeployConfig() + +const subject = ref('') +const messageBody = ref('') +const emailInput = ref(email.value) +const attachments = ref([]) +const fileInput = ref() + +const debouncedSearch = useDebounceFn(searchDocs, 250) +watch(query, () => debouncedSearch()) + +const hasQuery = computed(() => query.value.trim().length >= 2) +const showBack = computed(() => view.value === 'article' || view.value === 'compose') +const backLabel = computed(() => + view.value === 'article' ? 'All articles' : 'New conversation', +) + +const resultLabel = computed(() => { + if (isSearching.value) return 'Searching' + return results.value.length === 1 ? '1 result' : results.value.length + ' results' +}) + +const canSend = computed(() => + !!subject.value.trim() && !!messageBody.value.trim() && /.+@.+\..+/.test(emailInput.value), +) + +const onPickFiles = () => fileInput.value?.click() +const onFilesChosen = (event: Event) => { + const list = (event.target as HTMLInputElement).files + if (list) attachments.value = [...attachments.value, ...Array.from(list)] +} +const removeAttachment = (index: number) => { + attachments.value = attachments.value.filter((_, i) => i !== index) +} + +const onSend = () => send({ + subject: subject.value, + body: messageBody.value, + email: emailInput.value, + attachments: attachments.value, +}) + +const fileExtension = (name: string) => name.split('.').pop() ?? 'file' +const fileSizeKb = (size: number) => Math.max(1, Math.round(size / 1024)) + + +