@unitninety/site-kit 0.0.0-stage → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -2
- package/package.json +79 -4
- package/src/bridge/DraftBlocks.tsx +70 -0
- package/src/bridge/DraftEntries.tsx +41 -0
- package/src/bridge/PreviewCommitBoundary.tsx +19 -0
- package/src/bridge/U90Bridge.tsx +54 -0
- package/src/bridge/caret.ts +317 -0
- package/src/bridge/client.tsx +66 -0
- package/src/bridge/createBridge.ts +1015 -0
- package/src/bridge/hooks.tsx +56 -0
- package/src/bridge/index.ts +9 -0
- package/src/bridge/lexical.ts +177 -0
- package/src/bridge/lexicalDom.ts +212 -0
- package/src/bridge/preserveBlockMoves.ts +88 -0
- package/src/bridge/preview.ts +179 -0
- package/src/bridge/protocol.ts +273 -0
- package/src/bridge/registry.ts +30 -0
- package/src/bridge/styles.ts +137 -0
- package/src/components/DateText.tsx +9 -0
- package/src/components/Entries.tsx +16 -0
- package/src/components/File.tsx +9 -0
- package/src/components/Image.tsx +30 -0
- package/src/components/Link.tsx +24 -0
- package/src/components/NumberText.tsx +9 -0
- package/src/components/RichText.tsx +47 -0
- package/src/components/Text.tsx +15 -0
- package/src/components/Video.tsx +12 -0
- package/src/components/VideoPlayback.tsx +19 -0
- package/src/components/draft.ts +9 -0
- package/src/components/imageProps.ts +26 -0
- package/src/components/index.ts +13 -0
- package/src/components/videoProps.ts +6 -0
- package/src/elements/containers/group.ts +33 -0
- package/src/elements/containers/list.ts +31 -0
- package/src/elements/containers/zone.ts +41 -0
- package/src/elements/declaration.ts +118 -0
- package/src/elements/define.ts +195 -0
- package/src/elements/ids.ts +50 -0
- package/src/elements/index.ts +35 -0
- package/src/elements/jsonSchema.ts +36 -0
- package/src/elements/leaf.ts +98 -0
- package/src/elements/leaves/boolean.ts +30 -0
- package/src/elements/leaves/choice.ts +53 -0
- package/src/elements/leaves/date.ts +39 -0
- package/src/elements/leaves/file.ts +56 -0
- package/src/elements/leaves/image.ts +160 -0
- package/src/elements/leaves/link.ts +149 -0
- package/src/elements/leaves/number.ts +52 -0
- package/src/elements/leaves/query.ts +101 -0
- package/src/elements/leaves/relation.ts +69 -0
- package/src/elements/leaves/rich-text.ts +155 -0
- package/src/elements/leaves/selection.ts +40 -0
- package/src/elements/leaves/single.ts +29 -0
- package/src/elements/leaves/text.ts +103 -0
- package/src/elements/leaves/video.ts +108 -0
- package/src/elements/ops/expand.ts +94 -0
- package/src/elements/ops/overlay.ts +59 -0
- package/src/elements/ops/refs.ts +45 -0
- package/src/elements/ops/seed.ts +185 -0
- package/src/elements/ops/text.ts +20 -0
- package/src/elements/ops/translatable.ts +19 -0
- package/src/elements/ops/upgrade.ts +43 -0
- package/src/elements/ops/validate.ts +56 -0
- package/src/elements/order.ts +10 -0
- package/src/elements/path.ts +43 -0
- package/src/elements/prefix.ts +2 -0
- package/src/elements/registry.ts +48 -0
- package/src/elements/tokens.ts +14 -0
- package/src/elements/walk.ts +42 -0
- package/src/features/client/AskAiProvider.tsx +551 -0
- package/src/features/client/AskAiWidget.tsx +222 -0
- package/src/features/client/ConsentRuntime.tsx +95 -0
- package/src/features/client/FeedbackProvider.tsx +231 -0
- package/src/features/client/FeedbackWidget.tsx +101 -0
- package/src/features/client/PageReporter.tsx +26 -0
- package/src/features/client/README.md +131 -0
- package/src/features/client/SiteFeatures.tsx +250 -0
- package/src/features/client/askAiRecovery.ts +92 -0
- package/src/features/client/askAiStream.ts +59 -0
- package/src/features/client/context.tsx +24 -0
- package/src/features/client/index.ts +24 -0
- package/src/features/client/overlay.tsx +129 -0
- package/src/features/client/styles.css +337 -0
- package/src/features/client/transport.ts +310 -0
- package/src/features/client/useAskAI.ts +45 -0
- package/src/features/client/useConsent.ts +15 -0
- package/src/features/client/useFeedback.ts +33 -0
- package/src/features/client/useSiteSearch.ts +50 -0
- package/src/features/client/verification.ts +141 -0
- package/src/features/consent/README.md +70 -0
- package/src/features/consent/cookieScript.ts +53 -0
- package/src/features/consent/custom.ts +89 -0
- package/src/features/consent/defaults.ts +40 -0
- package/src/features/consent/drivers.ts +233 -0
- package/src/features/consent/loader.ts +248 -0
- package/src/features/consent/registry.ts +398 -0
- package/src/features/contracts/askAi.ts +166 -0
- package/src/features/contracts/askAiSettings.ts +42 -0
- package/src/features/contracts/base.ts +107 -0
- package/src/features/contracts/consent.ts +150 -0
- package/src/features/contracts/feedback.ts +22 -0
- package/src/features/contracts/feedbackSettings.ts +49 -0
- package/src/features/contracts/index.ts +61 -0
- package/src/features/contracts/knowledge.ts +99 -0
- package/src/features/contracts/knowledgePolicy.ts +249 -0
- package/src/features/contracts/search.ts +24 -0
- package/src/features/contracts/verification.ts +52 -0
- package/src/features/contracts/verificationConfig.ts +9 -0
- package/src/features/server/ConsentDefaults.tsx +15 -0
- package/src/features/server/README.md +185 -0
- package/src/features/server/gateway.ts +318 -0
- package/src/features/server/http.ts +179 -0
- package/src/features/server/index.ts +6 -0
- package/src/features/server/session.ts +23 -0
- package/src/features/server/verification.ts +39 -0
- package/src/features/server/verifyVisitor.ts +69 -0
- package/src/languages/config.d.mts +5 -0
- package/src/languages/config.mjs +53 -0
- package/src/languages/decide.ts +72 -0
- package/src/languages/index.ts +4 -0
- package/src/languages/proxy.ts +108 -0
- package/src/languages/routing.ts +93 -0
- package/src/languages/wire.ts +19 -0
- package/src/legacy/bridge.tsx +1987 -0
- package/src/legacy/caret.ts +246 -0
- package/src/legacy/cli.mjs +189 -0
- package/src/legacy/content.ts +993 -0
- package/src/legacy/contract.ts +664 -0
- package/src/legacy/defineBlocks.ts +1602 -0
- package/src/legacy/defineSite.ts +623 -0
- package/src/legacy/defineSite.types.ts +270 -0
- package/src/legacy/draft.tsx +128 -0
- package/src/legacy/image.ts +433 -0
- package/src/legacy/index.ts +209 -0
- package/src/legacy/language-switcher.tsx +58 -0
- package/src/legacy/languageHints.ts +153 -0
- package/src/legacy/languages/fields.ts +131 -0
- package/src/legacy/languages/hash.ts +98 -0
- package/src/legacy/languages/health.ts +97 -0
- package/src/legacy/languages/identity.ts +247 -0
- package/src/legacy/languages/links.ts +81 -0
- package/src/legacy/languages/resolution.ts +56 -0
- package/src/legacy/languages/routing.ts +169 -0
- package/src/legacy/languages/runtime.ts +216 -0
- package/src/legacy/languages/seo.ts +95 -0
- package/src/legacy/languages/settings.ts +211 -0
- package/src/legacy/languages/types.ts +85 -0
- package/src/legacy/languages.ts +8 -0
- package/src/legacy/lexical.ts +170 -0
- package/src/legacy/lexicalDom.ts +166 -0
- package/src/legacy/media.ts +272 -0
- package/src/legacy/nav.ts +176 -0
- package/src/legacy/preview.ts +79 -0
- package/src/legacy/redirects.ts +474 -0
- package/src/legacy/richText.tsx +104 -0
- package/src/legacy/routes.ts +414 -0
- package/src/legacy/seo.ts +327 -0
- package/src/legacy/shape.ts +412 -0
- package/src/legacy/uploads.ts +233 -0
- package/src/legacy/verification.ts +42 -0
- package/src/preview-lock/contracts.ts +42 -0
- package/src/preview-lock/index.ts +3 -0
- package/src/preview-lock/screen.tsx +244 -0
- package/src/preview-lock/server.ts +416 -0
- package/src/redirects/config.d.mts +5 -0
- package/src/redirects/config.mjs +22 -0
- package/src/redirects/index.ts +3 -0
- package/src/redirects/location.ts +31 -0
- package/src/redirects/match.ts +199 -0
- package/src/redirects/proxy.ts +91 -0
- package/src/redirects/wire.ts +30 -0
- package/src/seo/jsonLd.ts +70 -0
- package/src/seo/metadata.ts +127 -0
- package/src/seo/robots.ts +14 -0
- package/src/seo/sitemap.ts +22 -0
- package/src/seo/title.ts +22 -0
- package/src/server/adapters.ts +73 -0
- package/src/server/createU90.ts +96 -0
- package/src/server/handlers.ts +164 -0
- package/src/server/index.ts +6 -0
- package/src/server/read.ts +207 -0
- package/src/server/render.tsx +46 -0
- package/src/server/routes.tsx +171 -0
- package/src/site/assemble.ts +109 -0
- package/src/site/defineSite.ts +132 -0
- package/src/site/documents.ts +18 -0
- package/src/site/fields.ts +59 -0
- package/src/site/index.ts +14 -0
- package/src/site/replies.ts +118 -0
- package/src/site/store.ts +45 -0
- package/src/site/types.ts +75 -0
- package/src/site/version.ts +1 -0
- package/src/site/wire.ts +125 -0
- package/src/switcher/LanguageSwitcher.tsx +42 -0
- package/src/test-preview/README.md +90 -0
- package/src/test-preview/binding.ts +54 -0
- package/src/test-preview/bridge.ts +16 -0
- package/src/test-preview/guard.ts +186 -0
- package/src/test-preview/media.ts +23 -0
- package/src/test-preview/protocol.d.mts +67 -0
- package/src/test-preview/protocol.mjs +222 -0
- package/src/test-preview/runtime.ts +168 -0
- package/src/test-preview/server.ts +10 -0
- package/src/testing/fake.ts +148 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/seed.ts +2 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { useEffect, useRef, useState, type CSSProperties } from 'react'
|
|
3
|
+
import { FeatureDialog } from './overlay'
|
|
4
|
+
import { useAskAI, type AskAiController } from './useAskAI'
|
|
5
|
+
/** Reusable accessible content; remixes may replace this entire presentation with the same hook. */
|
|
6
|
+
export function AskAiContent({
|
|
7
|
+
controller: c,
|
|
8
|
+
}: {
|
|
9
|
+
controller: Omit<AskAiController, 'launcherRef'>
|
|
10
|
+
}) {
|
|
11
|
+
const log = useRef<HTMLDivElement>(null)
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (log.current) log.current.scrollTop = log.current.scrollHeight
|
|
14
|
+
}, [c.messages, c.status])
|
|
15
|
+
const busy = c.status === 'sending' || c.status === 'streaming' || c.recovering
|
|
16
|
+
return (
|
|
17
|
+
<>
|
|
18
|
+
<div
|
|
19
|
+
className="u90-ai-log"
|
|
20
|
+
role="log"
|
|
21
|
+
aria-label="Conversation"
|
|
22
|
+
aria-live="polite"
|
|
23
|
+
aria-relevant="additions text"
|
|
24
|
+
ref={log}
|
|
25
|
+
>
|
|
26
|
+
{!c.messages.length && <p>{c.settings.greeting}</p>}
|
|
27
|
+
{c.messages.map((m) => (
|
|
28
|
+
<article key={m.id} className={'u90-ai-message u90-ai-' + m.role}>
|
|
29
|
+
<p className="u90-ai-role">
|
|
30
|
+
{m.role === 'user' ? 'You' : 'Assistant'}
|
|
31
|
+
{m.newAttempt ? ' · New attempt' : ''}
|
|
32
|
+
</p>
|
|
33
|
+
<p className="u90-ai-text">
|
|
34
|
+
{m.text ||
|
|
35
|
+
(m.state === 'queued'
|
|
36
|
+
? c.awaitingAnswer
|
|
37
|
+
? 'Saved answer is still pending.'
|
|
38
|
+
: 'Sending… Keep this page open until the answer starts; refreshing now may prevent recovery.'
|
|
39
|
+
: m.state === 'streaming'
|
|
40
|
+
? 'Preparing answer…'
|
|
41
|
+
: 'No answer received.')}
|
|
42
|
+
</p>
|
|
43
|
+
{m.role === 'assistant' && (
|
|
44
|
+
<>
|
|
45
|
+
{m.sources.length > 0 && (
|
|
46
|
+
<ul aria-label="Sources">
|
|
47
|
+
{m.sources.map((s) => (
|
|
48
|
+
<li key={JSON.stringify([s.kind, s.id])}>
|
|
49
|
+
{s.path === null ? <span>{s.title}</span> : <a href={s.path}>{s.title}</a>}
|
|
50
|
+
</li>
|
|
51
|
+
))}
|
|
52
|
+
</ul>
|
|
53
|
+
)}
|
|
54
|
+
{['failed', 'aborted', 'unknown'].includes(m.state) && (
|
|
55
|
+
<p className="u90-ai-state">
|
|
56
|
+
{m.state === 'unknown'
|
|
57
|
+
? 'Unconfirmed answer — may be incomplete.'
|
|
58
|
+
: 'Incomplete answer.'}
|
|
59
|
+
</p>
|
|
60
|
+
)}
|
|
61
|
+
{!['queued', 'streaming'].includes(m.state) && (
|
|
62
|
+
<div className="u90-ai-ratings" aria-label="Rate answer">
|
|
63
|
+
{(['helpful', 'unhelpful'] as const).map((r) => (
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
key={r}
|
|
67
|
+
aria-pressed={c.ratings[m.id] === r}
|
|
68
|
+
onClick={() => void c.rate(m.id, r)}
|
|
69
|
+
>
|
|
70
|
+
{r === 'helpful' ? 'Helpful' : 'Unhelpful'}
|
|
71
|
+
</button>
|
|
72
|
+
))}
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
</>
|
|
76
|
+
)}
|
|
77
|
+
</article>
|
|
78
|
+
))}
|
|
79
|
+
</div>
|
|
80
|
+
{!c.messages.length && c.settings.suggestions.length > 0 && (
|
|
81
|
+
<div className="u90-ai-suggestions">
|
|
82
|
+
{c.settings.suggestions.map((s) => (
|
|
83
|
+
<button type="button" key={s} onClick={() => c.setDraft(s)}>
|
|
84
|
+
{s}
|
|
85
|
+
</button>
|
|
86
|
+
))}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
{c.hasEarlier && (
|
|
90
|
+
<p>
|
|
91
|
+
Some history has not been loaded.{' '}
|
|
92
|
+
<button type="button" disabled={busy} onClick={() => void c.loadEarlier()}>
|
|
93
|
+
Load earlier messages
|
|
94
|
+
</button>
|
|
95
|
+
</p>
|
|
96
|
+
)}
|
|
97
|
+
{c.error && (
|
|
98
|
+
<div role="alert" className="u90-ai-error">
|
|
99
|
+
<p>{c.error}</p>
|
|
100
|
+
{c.canRetry && (
|
|
101
|
+
<button type="button" onClick={() => void c.retry()}>
|
|
102
|
+
Retry same request
|
|
103
|
+
</button>
|
|
104
|
+
)}
|
|
105
|
+
{c.canNewAttempt && (
|
|
106
|
+
<button type="button" onClick={() => void c.retryAnswer()}>
|
|
107
|
+
Try a new answer
|
|
108
|
+
</button>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
{c.canRecover && (
|
|
113
|
+
<button type="button" disabled={busy} onClick={() => void c.recover()}>
|
|
114
|
+
Check saved answer
|
|
115
|
+
</button>
|
|
116
|
+
)}
|
|
117
|
+
{c.ratingError && (
|
|
118
|
+
<p role="alert" className="u90-ai-error">
|
|
119
|
+
{c.ratingError}
|
|
120
|
+
</p>
|
|
121
|
+
)}
|
|
122
|
+
<p role="status" className="u90-ai-progress">
|
|
123
|
+
{c.recovering
|
|
124
|
+
? 'Loading saved conversation…'
|
|
125
|
+
: c.awaitingAnswer
|
|
126
|
+
? 'Saved answer is still pending. Check the saved answer to refresh it.'
|
|
127
|
+
: c.status === 'sending'
|
|
128
|
+
? 'Sending… Keep this page open until the answer starts; refreshing now may prevent recovery.'
|
|
129
|
+
: c.status === 'streaming'
|
|
130
|
+
? 'Answering…'
|
|
131
|
+
: ''}
|
|
132
|
+
</p>
|
|
133
|
+
<form
|
|
134
|
+
className="u90-ai-composer"
|
|
135
|
+
onSubmit={(e) => {
|
|
136
|
+
e.preventDefault()
|
|
137
|
+
void c.send(c.draft)
|
|
138
|
+
}}
|
|
139
|
+
>
|
|
140
|
+
<label>
|
|
141
|
+
Message
|
|
142
|
+
<textarea
|
|
143
|
+
rows={2}
|
|
144
|
+
maxLength={2000}
|
|
145
|
+
value={c.draft}
|
|
146
|
+
onChange={(e) => c.setDraft(e.target.value)}
|
|
147
|
+
/>
|
|
148
|
+
</label>
|
|
149
|
+
<div>
|
|
150
|
+
<button
|
|
151
|
+
className="u90-ai-send"
|
|
152
|
+
type="submit"
|
|
153
|
+
disabled={busy || c.awaitingAnswer || !c.draft.trim() || c.canRetry || c.canNewAttempt}
|
|
154
|
+
>
|
|
155
|
+
Send
|
|
156
|
+
</button>
|
|
157
|
+
{busy && (
|
|
158
|
+
<button type="button" onClick={c.stop}>
|
|
159
|
+
Stop
|
|
160
|
+
</button>
|
|
161
|
+
)}
|
|
162
|
+
<button type="button" onClick={c.startNewConversation}>
|
|
163
|
+
New conversation
|
|
164
|
+
</button>
|
|
165
|
+
</div>
|
|
166
|
+
</form>
|
|
167
|
+
</>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
export function askAiForeground(hex: string) {
|
|
171
|
+
const channels = [1, 3, 5]
|
|
172
|
+
.map((i) => parseInt(hex.slice(i, i + 2), 16) / 255)
|
|
173
|
+
.map((v) => (v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
174
|
+
const light = 0.2126 * channels[0] + 0.7152 * channels[1] + 0.0722 * channels[2]
|
|
175
|
+
return (light + 0.05) / 0.05 >= 1.05 / (light + 0.05) ? '#000000' : '#ffffff'
|
|
176
|
+
}
|
|
177
|
+
export default function AskAiWidget() {
|
|
178
|
+
const { launcherRef, ...c } = useAskAI(),
|
|
179
|
+
[viewport, setViewport] = useState({ height: 0, top: 0 })
|
|
180
|
+
useEffect(() => {
|
|
181
|
+
const vv = window.visualViewport
|
|
182
|
+
const measure = () =>
|
|
183
|
+
setViewport({ height: vv?.height ?? window.innerHeight, top: vv?.offsetTop ?? 0 })
|
|
184
|
+
measure()
|
|
185
|
+
vv?.addEventListener('resize', measure)
|
|
186
|
+
vv?.addEventListener('scroll', measure)
|
|
187
|
+
window.addEventListener('resize', measure)
|
|
188
|
+
return () => {
|
|
189
|
+
vv?.removeEventListener('resize', measure)
|
|
190
|
+
vv?.removeEventListener('scroll', measure)
|
|
191
|
+
window.removeEventListener('resize', measure)
|
|
192
|
+
}
|
|
193
|
+
}, [])
|
|
194
|
+
return (
|
|
195
|
+
<div
|
|
196
|
+
className="u90-ai"
|
|
197
|
+
style={
|
|
198
|
+
{
|
|
199
|
+
'--u90-ai-accent': c.settings.accentColor,
|
|
200
|
+
'--u90-ai-on-accent': askAiForeground(c.settings.accentColor),
|
|
201
|
+
'--u90-ai-radius': c.settings.cornerStyle === 'square' ? '0' : '1rem',
|
|
202
|
+
'--u90-ai-height': viewport.height ? viewport.height + 'px' : '100dvh',
|
|
203
|
+
'--u90-ai-top': viewport.top + 'px',
|
|
204
|
+
} as CSSProperties
|
|
205
|
+
}
|
|
206
|
+
>
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
className="u90-ai-launcher"
|
|
210
|
+
ref={launcherRef}
|
|
211
|
+
onClick={c.show}
|
|
212
|
+
aria-expanded={c.open}
|
|
213
|
+
>
|
|
214
|
+
{c.settings.launcherLabel}
|
|
215
|
+
</button>
|
|
216
|
+
<FeatureDialog name="askAi" label={c.settings.title} className="u90-ai-panel">
|
|
217
|
+
<h2>{c.settings.title}</h2>
|
|
218
|
+
<AskAiContent controller={c} />
|
|
219
|
+
</FeatureDialog>
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import {
|
|
3
|
+
Suspense,
|
|
4
|
+
useCallback,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import type { PublicFeatureBase } from '../contracts'
|
|
12
|
+
import { deniedConsentState } from '../contracts/consent'
|
|
13
|
+
import type { CustomConsentRegistration } from '../consent/custom'
|
|
14
|
+
import { consentRegistry } from '../consent/registry'
|
|
15
|
+
import { ConsentContext } from './useConsent'
|
|
16
|
+
import { PageReporter } from './PageReporter'
|
|
17
|
+
const ignoreTitle = () => {}
|
|
18
|
+
/** Commit-only ownership: rendering never installs listeners, scripts, or authority. */
|
|
19
|
+
export function ConsentRuntime({
|
|
20
|
+
endpoint,
|
|
21
|
+
config,
|
|
22
|
+
customConsent,
|
|
23
|
+
children,
|
|
24
|
+
}: {
|
|
25
|
+
endpoint: string
|
|
26
|
+
config: PublicFeatureBase | null
|
|
27
|
+
customConsent?: CustomConsentRegistration
|
|
28
|
+
children: ReactNode
|
|
29
|
+
}) {
|
|
30
|
+
const owner = useRef({})
|
|
31
|
+
const registry = useRef<ReturnType<typeof consentRegistry> | null>(null)
|
|
32
|
+
const [snapshot, setSnapshot] = useState(() => ({
|
|
33
|
+
state: deniedConsentState(),
|
|
34
|
+
ready: false,
|
|
35
|
+
error: null as string | null,
|
|
36
|
+
reloadRequired: false,
|
|
37
|
+
}))
|
|
38
|
+
useLayoutEffect(() => {
|
|
39
|
+
const current = consentRegistry(window)
|
|
40
|
+
registry.current = current
|
|
41
|
+
const off = current.subscribe(() => setSnapshot(current.read()))
|
|
42
|
+
const currentOwner = owner.current
|
|
43
|
+
current.attach(currentOwner)
|
|
44
|
+
return () => {
|
|
45
|
+
off()
|
|
46
|
+
current.detach(currentOwner)
|
|
47
|
+
registry.current = null
|
|
48
|
+
}
|
|
49
|
+
}, [])
|
|
50
|
+
useLayoutEffect(() => {
|
|
51
|
+
registry.current!.commit(
|
|
52
|
+
owner.current,
|
|
53
|
+
endpoint,
|
|
54
|
+
config?.enabled.consent ?? false,
|
|
55
|
+
config?.consent,
|
|
56
|
+
customConsent,
|
|
57
|
+
)
|
|
58
|
+
setSnapshot(registry.current!.read())
|
|
59
|
+
}, [endpoint, config, customConsent, customConsent?.adapter, customConsent?.openPreferences])
|
|
60
|
+
const openPreferences = useCallback(() => registry.current?.openPreferences(), [])
|
|
61
|
+
const page = useCallback((path: string) => registry.current?.page(path), [])
|
|
62
|
+
const value = useMemo(
|
|
63
|
+
() => ({
|
|
64
|
+
state: snapshot.state,
|
|
65
|
+
ready: snapshot.ready,
|
|
66
|
+
error: snapshot.error,
|
|
67
|
+
openPreferences,
|
|
68
|
+
}),
|
|
69
|
+
[snapshot, openPreferences],
|
|
70
|
+
)
|
|
71
|
+
return (
|
|
72
|
+
<ConsentContext.Provider value={value}>
|
|
73
|
+
{children}
|
|
74
|
+
<Suspense fallback={null}>
|
|
75
|
+
<PageReporter report={page} titleChanged={ignoreTitle} />
|
|
76
|
+
</Suspense>
|
|
77
|
+
{snapshot.error && (
|
|
78
|
+
<div className="u90-features u90-consent-status">
|
|
79
|
+
<p role="status" aria-live="polite">
|
|
80
|
+
{snapshot.error}
|
|
81
|
+
</p>
|
|
82
|
+
{snapshot.reloadRequired ? (
|
|
83
|
+
<button type="button" data-u90-consent-reload onClick={() => window.location.reload()}>
|
|
84
|
+
Reload to apply consent changes
|
|
85
|
+
</button>
|
|
86
|
+
) : (
|
|
87
|
+
<button type="button" onClick={() => registry.current?.retry()}>
|
|
88
|
+
Retry consent setup
|
|
89
|
+
</button>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
)}
|
|
93
|
+
</ConsentContext.Provider>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import {
|
|
3
|
+
Suspense,
|
|
4
|
+
useCallback,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ComponentType,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from 'react'
|
|
12
|
+
import {
|
|
13
|
+
FeedbackInputSchema,
|
|
14
|
+
FeedbackWidgetSettingsSchema,
|
|
15
|
+
type FeedbackInput,
|
|
16
|
+
type PageContext,
|
|
17
|
+
} from '../contracts'
|
|
18
|
+
import { useSiteFeatures } from './context'
|
|
19
|
+
import { FeedbackContext, type FeedbackAnswer, type FeedbackController } from './useFeedback'
|
|
20
|
+
import { PageReporter } from './PageReporter'
|
|
21
|
+
|
|
22
|
+
type Status = FeedbackController['status']
|
|
23
|
+
const defaults = FeedbackWidgetSettingsSchema.parse({})
|
|
24
|
+
const uncertain =
|
|
25
|
+
'We could not confirm your feedback. Retry the same feedback, or start new feedback.'
|
|
26
|
+
/** Independent of presentation remounts, but owned by the endpoint-keyed root. */
|
|
27
|
+
export function FeedbackProvider({
|
|
28
|
+
children,
|
|
29
|
+
presentation: Presentation,
|
|
30
|
+
}: {
|
|
31
|
+
children: ReactNode
|
|
32
|
+
presentation?: ComponentType
|
|
33
|
+
}) {
|
|
34
|
+
const runtime = useSiteFeatures()
|
|
35
|
+
const [currentPage, setPage] = useState<Readonly<PageContext> | null>(null)
|
|
36
|
+
const [capturedPage, setCapturedPage] = useState<Readonly<PageContext> | null>(null)
|
|
37
|
+
const [epoch, setEpoch] = useState(0)
|
|
38
|
+
const [status, setStatus] = useState<Status>('idle')
|
|
39
|
+
const [error, setError] = useState<string | null>(null)
|
|
40
|
+
const attempt = useRef<FeedbackInput | null>(null)
|
|
41
|
+
const state = useRef<Status>('idle')
|
|
42
|
+
const pending = useRef<Promise<void> | null>(null)
|
|
43
|
+
const operation = useRef<AbortController | null>(null)
|
|
44
|
+
const alive = useRef(false)
|
|
45
|
+
const titleReady = useRef(false)
|
|
46
|
+
const committed = useRef({
|
|
47
|
+
enabled: false,
|
|
48
|
+
page: null as Readonly<PageContext> | null,
|
|
49
|
+
settings: defaults,
|
|
50
|
+
send: runtime.submitFeedback,
|
|
51
|
+
})
|
|
52
|
+
const transition = useCallback((next: Status, message: string | null = null) => {
|
|
53
|
+
state.current = next
|
|
54
|
+
setStatus(next)
|
|
55
|
+
setError(message)
|
|
56
|
+
}, [])
|
|
57
|
+
useLayoutEffect(() => {
|
|
58
|
+
alive.current = true
|
|
59
|
+
return () => {
|
|
60
|
+
alive.current = false
|
|
61
|
+
operation.current?.abort()
|
|
62
|
+
}
|
|
63
|
+
}, [])
|
|
64
|
+
useLayoutEffect(() => {
|
|
65
|
+
const enabled = Boolean(runtime.config?.enabled.feedback)
|
|
66
|
+
const wasEnabled = committed.current.enabled
|
|
67
|
+
committed.current = {
|
|
68
|
+
...committed.current,
|
|
69
|
+
enabled,
|
|
70
|
+
settings: runtime.config?.feedback ?? defaults,
|
|
71
|
+
send: runtime.submitFeedback,
|
|
72
|
+
}
|
|
73
|
+
if (wasEnabled && !enabled) {
|
|
74
|
+
operation.current?.abort()
|
|
75
|
+
operation.current = null
|
|
76
|
+
pending.current = null
|
|
77
|
+
setEpoch((value) => value + 1)
|
|
78
|
+
if (state.current === 'submitting') transition('error', uncertain)
|
|
79
|
+
else if (!attempt.current) transition('idle')
|
|
80
|
+
}
|
|
81
|
+
}, [runtime.config, runtime.submitFeedback, transition])
|
|
82
|
+
const report = useCallback(
|
|
83
|
+
(path: string) => {
|
|
84
|
+
if (committed.current.page?.path === path) return
|
|
85
|
+
titleReady.current = committed.current.page === null
|
|
86
|
+
const page = Object.freeze({ path })
|
|
87
|
+
committed.current = { ...committed.current, page }
|
|
88
|
+
setPage(page)
|
|
89
|
+
setEpoch((value) => value + 1)
|
|
90
|
+
if (!attempt.current || state.current === 'success') {
|
|
91
|
+
attempt.current = null
|
|
92
|
+
setCapturedPage(null)
|
|
93
|
+
transition('idle')
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
[transition],
|
|
97
|
+
)
|
|
98
|
+
const titleChanged = useCallback(() => {
|
|
99
|
+
if (committed.current.page?.path === window.location.pathname) titleReady.current = true
|
|
100
|
+
}, [])
|
|
101
|
+
const run = useCallback((): Promise<void> => {
|
|
102
|
+
if (pending.current) return pending.current
|
|
103
|
+
if (
|
|
104
|
+
!alive.current ||
|
|
105
|
+
!committed.current.enabled ||
|
|
106
|
+
!attempt.current ||
|
|
107
|
+
state.current === 'success'
|
|
108
|
+
)
|
|
109
|
+
return Promise.resolve()
|
|
110
|
+
const captured = attempt.current
|
|
111
|
+
const controller = new AbortController()
|
|
112
|
+
operation.current = controller
|
|
113
|
+
transition('submitting')
|
|
114
|
+
const signal = AbortSignal.any([controller.signal, AbortSignal.timeout(120000)])
|
|
115
|
+
const current = () =>
|
|
116
|
+
alive.current &&
|
|
117
|
+
committed.current.enabled &&
|
|
118
|
+
operation.current === controller &&
|
|
119
|
+
attempt.current === captured &&
|
|
120
|
+
!signal.aborted
|
|
121
|
+
// The pending slot is claimed synchronously, before either consumer can submit again.
|
|
122
|
+
const promise = Promise.resolve()
|
|
123
|
+
.then(async () => {
|
|
124
|
+
signal.throwIfAborted()
|
|
125
|
+
await committed.current.send(captured, signal)
|
|
126
|
+
if (current()) transition('success')
|
|
127
|
+
})
|
|
128
|
+
.catch(() => {
|
|
129
|
+
if (
|
|
130
|
+
alive.current &&
|
|
131
|
+
committed.current.enabled &&
|
|
132
|
+
operation.current === controller &&
|
|
133
|
+
attempt.current === captured
|
|
134
|
+
)
|
|
135
|
+
transition('error', uncertain)
|
|
136
|
+
})
|
|
137
|
+
.finally(() => {
|
|
138
|
+
if (operation.current === controller) {
|
|
139
|
+
operation.current = null
|
|
140
|
+
pending.current = null
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
pending.current = promise
|
|
144
|
+
return promise
|
|
145
|
+
}, [transition])
|
|
146
|
+
const submit = useCallback(
|
|
147
|
+
(answer: FeedbackAnswer): Promise<void> => {
|
|
148
|
+
if (pending.current) return pending.current
|
|
149
|
+
if (attempt.current) return run() // Retried answers and source are immutable.
|
|
150
|
+
const { enabled, page, settings } = committed.current
|
|
151
|
+
if (!alive.current || !enabled) return Promise.resolve()
|
|
152
|
+
if (!page || page.path !== window.location.pathname) {
|
|
153
|
+
setError('The page is changing. Please try again once it is ready.')
|
|
154
|
+
return Promise.resolve()
|
|
155
|
+
}
|
|
156
|
+
const comment = settings.showComment ? answer.comment?.trim() : undefined
|
|
157
|
+
// Validate before allocating an intentional request identity.
|
|
158
|
+
const parsed = FeedbackInputSchema.safeParse({
|
|
159
|
+
requestId: '00000000-0000-4000-8000-000000000000',
|
|
160
|
+
page: {
|
|
161
|
+
path: page.path,
|
|
162
|
+
...(titleReady.current && document.title ? { title: document.title.slice(0, 200) } : {}),
|
|
163
|
+
},
|
|
164
|
+
...(settings.showRating && answer.rating !== undefined ? { rating: answer.rating } : {}),
|
|
165
|
+
...(comment ? { comment } : {}),
|
|
166
|
+
})
|
|
167
|
+
if (!parsed.success) {
|
|
168
|
+
setError('Choose a rating or enter a comment of up to 2,000 characters.')
|
|
169
|
+
return Promise.resolve()
|
|
170
|
+
}
|
|
171
|
+
attempt.current = Object.freeze({
|
|
172
|
+
...parsed.data,
|
|
173
|
+
page: Object.freeze(parsed.data.page),
|
|
174
|
+
requestId: crypto.randomUUID(),
|
|
175
|
+
})
|
|
176
|
+
setCapturedPage(attempt.current.page)
|
|
177
|
+
return run()
|
|
178
|
+
},
|
|
179
|
+
[run],
|
|
180
|
+
)
|
|
181
|
+
const startNew = useCallback(() => {
|
|
182
|
+
if (state.current === 'submitting') return
|
|
183
|
+
attempt.current = null
|
|
184
|
+
setCapturedPage(null)
|
|
185
|
+
transition('idle')
|
|
186
|
+
setEpoch((value) => value + 1)
|
|
187
|
+
}, [transition])
|
|
188
|
+
const { open, close } = runtime.overlay
|
|
189
|
+
const show = useCallback(() => open('feedback'), [open])
|
|
190
|
+
const value = useMemo(
|
|
191
|
+
() => ({
|
|
192
|
+
open: runtime.overlay.active === 'feedback',
|
|
193
|
+
status,
|
|
194
|
+
error,
|
|
195
|
+
currentPage,
|
|
196
|
+
page: capturedPage ?? currentPage,
|
|
197
|
+
show,
|
|
198
|
+
hide: close,
|
|
199
|
+
submit,
|
|
200
|
+
retry: run,
|
|
201
|
+
startNew,
|
|
202
|
+
}),
|
|
203
|
+
[
|
|
204
|
+
runtime.overlay.active,
|
|
205
|
+
status,
|
|
206
|
+
error,
|
|
207
|
+
currentPage,
|
|
208
|
+
capturedPage,
|
|
209
|
+
show,
|
|
210
|
+
close,
|
|
211
|
+
submit,
|
|
212
|
+
run,
|
|
213
|
+
startNew,
|
|
214
|
+
],
|
|
215
|
+
)
|
|
216
|
+
return (
|
|
217
|
+
<FeedbackContext.Provider value={value}>
|
|
218
|
+
<Suspense fallback={null}>
|
|
219
|
+
<PageReporter report={report} titleChanged={titleChanged} />
|
|
220
|
+
</Suspense>
|
|
221
|
+
{children}
|
|
222
|
+
{runtime.config?.enabled.feedback && Presentation && (
|
|
223
|
+
<Suspense fallback={null}>
|
|
224
|
+
<div className="u90-features">
|
|
225
|
+
<Presentation key={epoch} />
|
|
226
|
+
</div>
|
|
227
|
+
</Suspense>
|
|
228
|
+
)}
|
|
229
|
+
</FeedbackContext.Provider>
|
|
230
|
+
)
|
|
231
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { useId, useState } from 'react'
|
|
3
|
+
import { FeedbackWidgetSettingsSchema } from '../contracts'
|
|
4
|
+
import { useSiteFeatures } from './context'
|
|
5
|
+
import { useFeedback, type FeedbackAnswer } from './useFeedback'
|
|
6
|
+
import { FeatureDialog } from './overlay'
|
|
7
|
+
const defaults = FeedbackWidgetSettingsSchema.parse({})
|
|
8
|
+
export default function FeedbackWidget() {
|
|
9
|
+
const { launcherRef, ...feedback } = useFeedback()
|
|
10
|
+
const settings = useSiteFeatures().config?.feedback ?? defaults
|
|
11
|
+
const [rating, setRating] = useState<FeedbackAnswer['rating']>()
|
|
12
|
+
const [comment, setComment] = useState('')
|
|
13
|
+
const id = useId()
|
|
14
|
+
return (
|
|
15
|
+
<>
|
|
16
|
+
<button
|
|
17
|
+
className="u90-feedback-launcher"
|
|
18
|
+
ref={launcherRef}
|
|
19
|
+
type="button"
|
|
20
|
+
aria-expanded={feedback.open}
|
|
21
|
+
onClick={feedback.show}
|
|
22
|
+
>
|
|
23
|
+
{settings.launcherLabel}
|
|
24
|
+
</button>
|
|
25
|
+
<FeatureDialog name="feedback" label={settings.title} className="u90-feedback-panel">
|
|
26
|
+
<h2>{settings.title}</h2>
|
|
27
|
+
<p className="u90-feedback-page">Feedback for {feedback.page?.path ?? 'this page'}</p>
|
|
28
|
+
{feedback.page?.path !== feedback.currentPage?.path && (
|
|
29
|
+
<p>
|
|
30
|
+
You are now on {feedback.currentPage?.path}. This feedback still belongs to{' '}
|
|
31
|
+
{feedback.page?.path}.
|
|
32
|
+
</p>
|
|
33
|
+
)}
|
|
34
|
+
{feedback.status === 'idle' && (
|
|
35
|
+
<form
|
|
36
|
+
onSubmit={(event) => {
|
|
37
|
+
event.preventDefault()
|
|
38
|
+
void feedback.submit({ rating, comment })
|
|
39
|
+
}}
|
|
40
|
+
>
|
|
41
|
+
{settings.showRating && (
|
|
42
|
+
<fieldset>
|
|
43
|
+
<legend>{settings.ratingLabel}</legend>
|
|
44
|
+
<div className="u90-feedback-ratings">
|
|
45
|
+
{([1, 2, 3, 4, 5] as const).map((value) => (
|
|
46
|
+
<label key={value}>
|
|
47
|
+
<input
|
|
48
|
+
type="radio"
|
|
49
|
+
name={id}
|
|
50
|
+
aria-label={`${value} out of 5`}
|
|
51
|
+
checked={rating === value}
|
|
52
|
+
onChange={() => setRating(value)}
|
|
53
|
+
/>
|
|
54
|
+
<span>{value}</span>
|
|
55
|
+
</label>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
</fieldset>
|
|
59
|
+
)}
|
|
60
|
+
{settings.showComment && (
|
|
61
|
+
<label className="u90-feedback-comment">
|
|
62
|
+
{settings.commentLabel}
|
|
63
|
+
<textarea
|
|
64
|
+
value={comment}
|
|
65
|
+
onChange={(event) => setComment(event.target.value)}
|
|
66
|
+
maxLength={2000}
|
|
67
|
+
rows={4}
|
|
68
|
+
placeholder={settings.commentPlaceholder}
|
|
69
|
+
/>
|
|
70
|
+
</label>
|
|
71
|
+
)}
|
|
72
|
+
{feedback.error && <p role="alert">{feedback.error}</p>}
|
|
73
|
+
<button className="u90-feedback-submit" type="submit">
|
|
74
|
+
{settings.submitLabel}
|
|
75
|
+
</button>
|
|
76
|
+
</form>
|
|
77
|
+
)}
|
|
78
|
+
{feedback.status === 'submitting' && <p role="status">Sending feedback…</p>}
|
|
79
|
+
{feedback.status === 'error' && (
|
|
80
|
+
<>
|
|
81
|
+
<p role="alert">{feedback.error}</p>
|
|
82
|
+
<button type="button" onClick={() => void feedback.retry()}>
|
|
83
|
+
Retry feedback
|
|
84
|
+
</button>
|
|
85
|
+
<button type="button" onClick={feedback.startNew}>
|
|
86
|
+
Start new feedback
|
|
87
|
+
</button>
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
{feedback.status === 'success' && (
|
|
91
|
+
<>
|
|
92
|
+
<p role="status">{settings.successMessage}</p>
|
|
93
|
+
<button type="button" onClick={feedback.startNew}>
|
|
94
|
+
Start new feedback
|
|
95
|
+
</button>
|
|
96
|
+
</>
|
|
97
|
+
)}
|
|
98
|
+
</FeatureDialog>
|
|
99
|
+
</>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { useLayoutEffect } from 'react'
|
|
3
|
+
import { usePathname } from 'next/navigation'
|
|
4
|
+
/** Isolated Suspense leaf: only committed navigation advances the presentation lifetime. */
|
|
5
|
+
export function PageReporter({
|
|
6
|
+
report,
|
|
7
|
+
titleChanged,
|
|
8
|
+
}: {
|
|
9
|
+
report: (path: string) => void
|
|
10
|
+
titleChanged: () => void
|
|
11
|
+
}) {
|
|
12
|
+
const pathname = usePathname()
|
|
13
|
+
useLayoutEffect(() => {
|
|
14
|
+
report(window.location.pathname)
|
|
15
|
+
let title = document.title
|
|
16
|
+
const observer = new MutationObserver(() => {
|
|
17
|
+
if (document.title !== title) {
|
|
18
|
+
title = document.title
|
|
19
|
+
titleChanged()
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
observer.observe(document.head, { subtree: true, childList: true, characterData: true })
|
|
23
|
+
return () => observer.disconnect()
|
|
24
|
+
}, [pathname, report, titleChanged])
|
|
25
|
+
return null
|
|
26
|
+
}
|