mirror of
https://github.com/hanzo-apps/hanzo.ai.git
synced 2026-10-29 07:20:43 +00:00
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.
188 lines
7.5 KiB
TypeScript
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}
|
|
/>
|
|
)
|
|
}
|