Files
hanzo.ai/components/shell/help.tsx
zeekay 8bb3c633d2 consent: @hanzo/ui's one sheet, and our stream counts every visitor
components/consent/Consent.tsx and Choices.tsx are gone. app/providers.tsx
mounts Consent from @hanzo/ui/consent 8.29.14: a bar with Manage
preferences, Accept all and a × that stores essential cookies only, and
a Cookie Preferences sheet that opens from any link to #choices, the
footer's Privacy choices included. Its switches start off until chosen.

@hanzo/event 0.3.61 makes the stream first party: every page view and
event goes for every visitor, stating the grants it went under, so the
provider no longer takes `enabled` from consent. Consent gates Google
and Meta alone, through startTags.

The conversion and forward gates now hold that no platform hears of a
visit before Accept or after a refusal, while our stream counts it under
no grant. Help's corner words gain the Font and Arabic font rows
@hanzo/ui 8.29.12 added to Words.
2026-10-25 22:05:45 -07:00

188 lines
7.5 KiB
TypeScript

'use client'
/**
* Help and preferences, in the bottom corner of every page of the site
* (@hanzo/ui `Corner`): Get help, Docs and Contact us, then Theme and Language.
* It sits at the start corner, so under `dir="rtl"` it is bottom-right.
*
* - **Theme** is the package's own row (@hanzo/appearance). It writes this
* device's layer and repaints, and the boot script in app/layout.tsx paints
* it on the next load. It would write through the account's layers under
* `<Hanzo>` (`Look`); this site mounts its own gui provider.
* - **Language** is lib/language.ts: @hanzo/i18n's registry, less the locales
* waiting for review, and its setter, which sets `<html lang dir>`.
* - **No currency row.** hanzo.ai bills in US dollars only, and a row that
* offers one choice is a row that does nothing.
*
* NOT IN THE APP. The rooms (`owns`, components/shell/mode.ts) and the app that
* `/`, /dev and /studio draw for a signed-in reader (`APP` on <html>,
* app/_apex.tsx) keep the account at the rail's foot, bottom-left, and their own
* Settings is where preferences live there. Two controls in one corner is one
* too many, so this steps aside.
*
* Drawn once React owns the page, its script fetched then: it is a control and
* nothing else, and the export would otherwise paint it at a signed-in reader
* for the moment before the app takes `/`.
*
* THE COOKIE QUESTION COMES FIRST. The banner (@hanzo/ui `Consent`) stands on
* the foot of the window over this corner, and beside its rounded end the mark
* would still show and take a press. So while the banner asks (@hanzo/event
* `asks`) the corner is not drawn; answered, it is.
*/
import { useEffect, useSyncExternalStore } from 'react'
import dynamic from 'next/dynamic'
import { CONSENT_EVENT, POLICY_EVENT, asks } from '@hanzo/event'
import { BookOpen, Copy, GraduationCap, MessagesSquare } from '@hanzogui/lucide-icons-2'
import { phrase } from '@hanzo/i18n'
import { useI18n } from '@hanzo/i18n/react'
import { toast } from '@hanzo/ui'
import type { Words } from '@hanzo/ui/corner'
import { APP, hasSession } from '@hanzo/app/lib/session'
import { useHydrated } from '@hanzo/app/lib/hydrated'
import { track } from '@hanzo/app/lib/tags'
import { useIam } from '@hanzo/iam/react'
import { useLanguage } from '@/lib/language'
import { DOCS } from '@/components/home/nav-data'
import { owns } from './mode'
import { useRoute } from './route'
/**
* The control, fetched once it is to be drawn: no page carries its script in its
* first load.
*/
const Corner = dynamic(() => import('@hanzo/ui/corner').then((m) => m.Corner), { ssr: false })
/**
* The panel's own words, the package's English (@hanzo/ui/corner `WORDS`) written
* here with `phrase`, so the extractor finds each one, and said in the reader's
* language where the corner is drawn. Typed as the package's `Words`, so a key it
* adds fails the typecheck until it is written here.
*/
const WORDS: Words = {
open: phrase('Help and preferences'),
help: phrase('Help'),
preferences: phrase('Preferences'),
theme: phrase('Theme'),
auto: phrase('Auto'),
light: phrase('Light'),
dark: phrase('Dark'),
language: phrase('Language'),
currency: phrase('Currency'),
font: phrase('Font'),
sans: phrase('Sans'),
system: phrase('System'),
serif: phrase('Serif'),
mono: phrase('Mono'),
arabic: phrase('Arabic font'),
kufi: phrase('Kufi'),
search: phrase('Search'),
back: phrase('Back'),
close: phrase('Close'),
none: phrase('No matches'),
leaves: phrase('opens in a new tab'),
}
const COPY_MARKDOWN_HREF = '#copy-markdown'
/** Where a reader goes when the page is not enough: the Help Center, the docs, a person, or copy page as Markdown for AI. */
const HELP = [
{ label: phrase('Copy page as Markdown (AI)'), href: COPY_MARKDOWN_HREF, Icon: Copy },
{ label: phrase('Get help'), href: '/support', Icon: GraduationCap },
{ label: phrase('Docs'), href: DOCS, Icon: BookOpen },
{ label: phrase('Contact us'), href: '/contact', Icon: MessagesSquare },
]
/** Extract clean, structured Markdown from the current page DOM for coding agents & LLMs. */
function pageToMarkdown(): string {
const title = document.title || 'Hanzo AI'
const url = window.location.href
const desc = document.querySelector<HTMLMetaElement>('meta[name="description"]')?.content ?? ''
const root = document.querySelector('main') ?? document.body
const lines: string[] = [`# ${title}`, '', `URL: ${url}`]
if (desc) lines.push(`Description: ${desc}`)
lines.push('', '---', '')
const seen = new Set<string>()
const blocks = root.querySelectorAll('h1, h2, h3, h4, p, li, pre, code')
blocks.forEach((el) => {
if (el.closest('[data-slot="consent"], header, footer, nav, [aria-hidden="true"]')) {
return
}
const tag = el.tagName.toLowerCase()
if (tag === 'code' && el.parentElement?.tagName.toLowerCase() === 'pre') return
const text = (el.textContent ?? '').replace(/\s+/g, ' ').trim()
if (!text || seen.has(`${tag}:${text}`)) return
seen.add(`${tag}:${text}`)
if (tag === 'h1') lines.push(`# ${text}`, '')
else if (tag === 'h2') lines.push(`## ${text}`, '')
else if (tag === 'h3') lines.push(`### ${text}`, '')
else if (tag === 'h4') lines.push(`#### ${text}`, '')
else if (tag === 'pre') lines.push('```', (el.textContent ?? '').trim(), '```', '')
else if (tag === 'li') lines.push(`- ${text}`)
else lines.push(text, '')
})
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim()
}
/** Whether the app is drawn at this address: `APP` on <html>, followed as it changes. */
const watch = (on: () => void) => {
const seen = new MutationObserver(on)
seen.observe(document.documentElement, { attributes: true, attributeFilter: [APP] })
return () => seen.disconnect()
}
const drawn = () => document.documentElement.hasAttribute(APP)
const server = () => false
/** Whether the cookie banner asks, followed as a choice is stored or cloud's policy arrives. */
const hear = (on: () => void) => {
window.addEventListener(CONSENT_EVENT, on)
window.addEventListener(POLICY_EVENT, on)
return () => {
window.removeEventListener(CONSENT_EVENT, on)
window.removeEventListener(POLICY_EVENT, on)
}
}
const asking = () => true
export function Help() {
const hydrated = useHydrated()
const route = useRoute()
const app = useSyncExternalStore(watch, drawn, server)
const banner = useSyncExternalStore(hear, asks, asking)
const { isAuthenticated } = useIam()
const { t } = useI18n()
const language = useLanguage()
useEffect(() => {
const onClick = (e: MouseEvent) => {
const a = (e.target as Element | null)?.closest?.(`a[href="${COPY_MARKDOWN_HREF}"]`)
if (!a) return
e.preventDefault()
e.stopPropagation()
const md = pageToMarkdown()
track('page_copied_markdown', { path: window.location.pathname })
void navigator.clipboard
?.writeText(md)
.then(() => {
toast.success(t('Copied page as Markdown for AI!'))
})
.catch(() => {})
}
document.addEventListener('click', onClick, true)
return () => document.removeEventListener('click', onClick, true)
}, [t])
if (!hydrated || app || banner || owns(route) || isAuthenticated || hasSession()) return null
const words = Object.fromEntries(Object.entries(WORDS).map(([key, said]) => [key, t(said)])) as Words
return (
<Corner
help={HELP.map(({ label, href, Icon }) => ({ label: t(label), href, icon: <Icon size={16} color="$ink" /> }))}
language={language}
words={words}
/>
)
}