@leavepulse/ui 0.57.0 → 0.58.1
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/dist/chunks/{ContextMenuItems-BtKU7074.js → ContextMenuItems-BY5sTVQF.js} +1 -1
- package/dist/chunks/{LayoutCanvas-BkaGXTjx.js → LayoutCanvas-BcGLJVBq.js} +1 -1
- package/dist/chunks/{LayoutNode-B3_9E1Xa.js → LayoutNode-BkBn6dvM.js} +1 -1
- package/dist/chunks/{LpActionBar-C3hkVsFz.js → LpActionBar-DCN-_pZP.js} +2 -2
- package/dist/chunks/{LpAddressInput-NUHmplCd.js → LpAddressInput-Dio6Sqed.js} +7 -7
- package/dist/chunks/{LpAlert-B6ilrM7T.js → LpAlert-CXMn1GYE.js} +1 -1
- package/dist/chunks/{LpAppShell-Bl1Ch68N.js → LpAppShell-Be6T96td.js} +3 -3
- package/dist/chunks/{LpAutocomplete-C8IgSMsi.js → LpAutocomplete-hJ_6CHlh.js} +1 -1
- package/dist/chunks/{LpAvatar-LTvy0OkE.js → LpAvatar-BxNs23Xu.js} +2 -2
- package/dist/chunks/{LpBreadcrumbs-D4TsoFju.js → LpBreadcrumbs-BLzOEgAF.js} +1 -1
- package/dist/chunks/{LpButton-BdfZFNqi.js → LpButton-vhlwmpBy.js} +1 -1
- package/dist/chunks/{LpCalendar-DrA2McTU.js → LpCalendar-BLoE8Qm7.js} +1 -1
- package/dist/chunks/{LpCard-DbjO5yaF.js → LpCard-DhR7u0d8.js} +1 -1
- package/dist/chunks/{LpCheckbox-DvVYeOD-.js → LpCheckbox-B_C8sOoT.js} +1 -1
- package/dist/chunks/{LpCodeBlock-ca7ANFyW.js → LpCodeBlock-CXsqJwDy.js} +1 -1
- package/dist/chunks/{LpColorPicker-D1sQqtXS.js → LpColorPicker-DQ88niXq.js} +2 -2
- package/dist/chunks/{LpConfirmDialog-CrbUZgJE.js → LpConfirmDialog-DxlSdjmk.js} +3 -3
- package/dist/chunks/{LpConfirmHost-BgMIV8r9.js → LpConfirmHost-BcrmTNu_.js} +1 -1
- package/dist/chunks/{LpContextMenu-EF_90u3g.js → LpContextMenu-B1NGfpp1.js} +2 -2
- package/dist/chunks/{LpDatePicker-BrdFtSTy.js → LpDatePicker-B-VMHMgy.js} +3 -3
- package/dist/chunks/{LpDateRangePicker-CxV5jW7p.js → LpDateRangePicker-B68KPKPl.js} +2 -2
- package/dist/chunks/{LpDisclosure-Cc23-JOT.js → LpDisclosure-Ct-VBHxt.js} +1 -1
- package/dist/chunks/{LpDrawer-D9lLOC0C.js → LpDrawer-DSknigxH.js} +1 -1
- package/dist/chunks/{LpDropdownMenu-DrpJJBp0.js → LpDropdownMenu-C7oHVlKY.js} +2 -2
- package/dist/chunks/{LpEmptyState-BJbZXC8D.js → LpEmptyState-CBwwkkdA.js} +1 -1
- package/dist/chunks/{LpFieldNotice-wtieLUTj.js → LpFieldNotice-9hH4ITZa.js} +1 -1
- package/dist/chunks/{LpFileTree-3E4w4Rjr.js → LpFileTree-CMA0_rsB.js} +2 -2
- package/dist/chunks/{LpFileTreeNode-B4uB8Ks_.js → LpFileTreeNode-B4T258hF.js} +3 -3
- package/dist/chunks/{LpFormField-CtVYj6tO.js → LpFormField-BpQ6WJPe.js} +1 -1
- package/dist/chunks/{LpHintLayer-CI1oZ9Pd.js → LpHintLayer-B5-6FyEf.js} +1 -1
- package/dist/chunks/LpHints-BqoZXHZk.js +109 -0
- package/dist/chunks/{LpIcon-DYtaid0K.js → LpIcon-B5guGKrW.js} +1 -1
- package/dist/chunks/{LpInput-D-YGQbEv.js → LpInput-WvyP02ID.js} +2 -2
- package/dist/chunks/{LpLightbox-EVXzAq62.js → LpLightbox-Dq-e36uT.js} +1 -1
- package/dist/chunks/{LpLogViewer-BfgtXVKm.js → LpLogViewer-DwiYE32z.js} +2 -2
- package/dist/chunks/{LpMapPicker-Cc84LaM3.js → LpMapPicker-BH5csaBt.js} +1 -1
- package/dist/chunks/{LpMediaGallery-BnlkN_1f.js → LpMediaGallery-B1kDypJ-.js} +2 -2
- package/dist/chunks/{LpModal-JNa3B_Lr.js → LpModal-BxYssnXg.js} +1 -1
- package/dist/chunks/{LpModalNav-DXyAyhQl.js → LpModalNav-Bn2ws64b.js} +4 -4
- package/dist/chunks/{LpNotificationBell-DEL-RoHS.js → LpNotificationBell-DJfs4JjQ.js} +4 -4
- package/dist/chunks/{LpNumberField-B77O-z28.js → LpNumberField-CJPjmoDz.js} +1 -1
- package/dist/chunks/{LpPagination-BcDlo3r_.js → LpPagination-DehAaJLY.js} +1 -1
- package/dist/chunks/{LpPasswordInput-eKzQhbQP.js → LpPasswordInput-Bg5uTZbx.js} +2 -2
- package/dist/chunks/{LpPhoneInput-BCbrrhgE.js → LpPhoneInput-DStwcbnQ.js} +2 -2
- package/dist/chunks/{LpPie-CXka8Jvx.js → LpPie-DeTkbbOK.js} +2 -2
- package/dist/chunks/{LpRangeCalendar-DZuaRV0W.js → LpRangeCalendar-D5sDID9W.js} +1 -1
- package/dist/chunks/{LpSection-tKEH0jYf.js → LpSection-B19VPYmr.js} +1 -1
- package/dist/chunks/{LpSegmented-C09EEam2.js → LpSegmented-D3Eam8sa.js} +1 -1
- package/dist/chunks/{LpSelect-DNIL6MB9.js → LpSelect-DynjWK1A.js} +1 -1
- package/dist/chunks/{LpSidebar-hZ8D1cVr.js → LpSidebar-CshW0fqC.js} +3 -3
- package/dist/chunks/{LpSidebarNav-C9dHu-2b.js → LpSidebarNav-C9b3i9G2.js} +2 -2
- package/dist/chunks/{LpStat-B-7udBRP.js → LpStat-BS2TEXYK.js} +1 -1
- package/dist/chunks/{LpStepper-C7XiVKZj.js → LpStepper-BSglWF45.js} +1 -1
- package/dist/chunks/{LpTableFilter-CrW7_Rj4.js → LpTableFilter-CwHXVxJt.js} +2 -2
- package/dist/chunks/{LpTabs-BMSBB78h.js → LpTabs-BBGRHY38.js} +1 -1
- package/dist/chunks/{LpTagsInput-D1SuqbhB.js → LpTagsInput-CHbQoAAA.js} +3 -3
- package/dist/chunks/{LpTextarea-uuScWo6-.js → LpTextarea-Bz2IWhTH.js} +1 -1
- package/dist/chunks/{LpThemeSwitcher-Fz85BCRS.js → LpThemeSwitcher-B_qhSl7q.js} +2 -2
- package/dist/chunks/{LpTimeField-Cqe7fXnh.js → LpTimeField-DvGXgdES.js} +1 -1
- package/dist/chunks/{LpToaster-DTtWuKvq.js → LpToaster-Bo9Q4QBP.js} +1 -1
- package/dist/chunks/{LpTour-C1TC2GyY.js → LpTour-D7m9ZxNk.js} +26 -7
- package/dist/chunks/{MenuTitle-J3ZDYjpV.js → MenuTitle-DQP6hc2N.js} +1 -1
- package/dist/chunks/{TourCard-BQS3ztqj.js → TourCard-DCHOjr6e.js} +2 -2
- package/dist/chunks/icons-QqBscG5I.js +43 -0
- package/dist/component-names.d.ts +1 -1
- package/dist/components/ContextMenuItems.vue.js +1 -1
- package/dist/components/LayoutCanvas.vue.js +1 -1
- package/dist/components/LayoutNode.vue.js +1 -1
- package/dist/components/LpActionBar.vue.js +1 -1
- package/dist/components/LpAddressInput.vue.js +1 -1
- package/dist/components/LpAlert.vue.js +1 -1
- package/dist/components/LpAppShell.vue.js +1 -1
- package/dist/components/LpAutocomplete.vue.js +1 -1
- package/dist/components/LpAvatar.vue.js +1 -1
- package/dist/components/LpBreadcrumbs.vue.js +1 -1
- package/dist/components/LpButton.vue.js +1 -1
- package/dist/components/LpCalendar.vue.js +1 -1
- package/dist/components/LpCard.vue.js +1 -1
- package/dist/components/LpChart.vue.js +2 -2
- package/dist/components/LpCheckbox.vue.js +1 -1
- package/dist/components/LpCodeBlock.vue.js +1 -1
- package/dist/components/LpColorPicker.vue.js +1 -1
- package/dist/components/LpCommandPalette.vue.js +2 -2
- package/dist/components/LpConfirmDialog.vue.js +1 -1
- package/dist/components/LpConfirmHost.vue.js +1 -1
- package/dist/components/LpContextMenu.vue.js +1 -1
- package/dist/components/LpDatePicker.vue.js +1 -1
- package/dist/components/LpDateRangePicker.vue.js +1 -1
- package/dist/components/LpDisclosure.vue.js +1 -1
- package/dist/components/LpDrawer.vue.js +1 -1
- package/dist/components/LpDropdownMenu.vue.js +1 -1
- package/dist/components/LpDropzone.vue.js +1 -1
- package/dist/components/LpEmptyState.vue.js +1 -1
- package/dist/components/LpFieldNotice.vue.js +1 -1
- package/dist/components/LpFileTree.vue.js +1 -1
- package/dist/components/LpFileTreeNode.vue.js +1 -1
- package/dist/components/LpFormField.vue.js +1 -1
- package/dist/components/LpHintLayer.vue.js +1 -1
- package/dist/components/LpHints.vue.d.ts +20 -0
- package/dist/components/LpHints.vue.js +4 -0
- package/dist/components/LpIcon.vue.js +1 -1
- package/dist/components/LpImageEditor.vue.js +2 -2
- package/dist/components/LpInput.vue.js +1 -1
- package/dist/components/LpLightbox.vue.js +1 -1
- package/dist/components/LpLogViewer.vue.js +1 -1
- package/dist/components/LpMapPicker.vue.js +1 -1
- package/dist/components/LpMediaGallery.vue.js +1 -1
- package/dist/components/LpModal.vue.js +1 -1
- package/dist/components/LpModalNav.vue.js +1 -1
- package/dist/components/LpNotificationBell.vue.js +1 -1
- package/dist/components/LpNumberField.vue.js +1 -1
- package/dist/components/LpPagination.vue.js +1 -1
- package/dist/components/LpPaletteFacts.vue.js +1 -1
- package/dist/components/LpPaletteHeader.vue.js +2 -2
- package/dist/components/LpPasswordInput.vue.js +1 -1
- package/dist/components/LpPhoneInput.vue.js +1 -1
- package/dist/components/LpPie.vue.js +1 -1
- package/dist/components/LpRangeCalendar.vue.js +1 -1
- package/dist/components/LpSection.vue.js +1 -1
- package/dist/components/LpSegmented.vue.js +1 -1
- package/dist/components/LpSelect.vue.js +1 -1
- package/dist/components/LpShortcutsDialog.vue.js +1 -1
- package/dist/components/LpSidebar.vue.js +1 -1
- package/dist/components/LpSidebarNav.vue.js +1 -1
- package/dist/components/LpStat.vue.js +1 -1
- package/dist/components/LpStepper.vue.js +1 -1
- package/dist/components/LpTable.vue.js +6 -6
- package/dist/components/LpTableFilter.vue.js +1 -1
- package/dist/components/LpTabs.vue.js +1 -1
- package/dist/components/LpTagsInput.vue.js +1 -1
- package/dist/components/LpTextarea.vue.js +1 -1
- package/dist/components/LpThemeSwitcher.vue.js +1 -1
- package/dist/components/LpTimeField.vue.js +1 -1
- package/dist/components/LpToaster.vue.js +1 -1
- package/dist/components/LpTour.vue.js +1 -1
- package/dist/components/MenuTitle.vue.js +1 -1
- package/dist/components/TourCard.vue.js +1 -1
- package/dist/composables/useHintSystem.d.ts +195 -0
- package/dist/composables/useHintSystem.js +334 -0
- package/dist/icons-all.js +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +73 -64
- package/package.json +1 -1
- package/src/component-names.ts +1 -0
- package/src/components/LpHints.vue +116 -0
- package/src/components/LpTour.vue +26 -6
- package/src/composables/useHintSystem.ts +512 -0
- package/src/index.ts +17 -0
- package/src/kit-icons.ts +1 -1
- package/dist/chunks/icons-CkA0njzm.js +0 -43
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Drop-in onboarding: put it once next to the app's root, hand it a hint
|
|
4
|
+
* system, and the tour offer, the "new" dots, "what changed" and nudges all
|
|
5
|
+
* work. Nothing to wire per page beyond `v-hint` on the elements.
|
|
6
|
+
*
|
|
7
|
+
* It must never cost the page itself. Any error thrown inside (a tour step's
|
|
8
|
+
* `enter`, a storage that rejects, a target that misbehaves) switches the layer
|
|
9
|
+
* off for the session and is logged once; the page under it keeps working.
|
|
10
|
+
*/
|
|
11
|
+
import { computed, onBeforeUnmount, onErrorCaptured, onMounted, ref, unref, watch } from "vue"
|
|
12
|
+
import { provideHintSystem, type HintSystem } from "../composables/useHintSystem"
|
|
13
|
+
import { useLabels } from "../composables/useLpConfig"
|
|
14
|
+
import LpActionBar, { type ActionBarAction } from "./LpActionBar.vue"
|
|
15
|
+
import LpHintLayer from "./LpHintLayer.vue"
|
|
16
|
+
import LpTour from "./LpTour.vue"
|
|
17
|
+
|
|
18
|
+
const props = defineProps<{
|
|
19
|
+
system: HintSystem
|
|
20
|
+
labels?: Partial<typeof DEFAULT_LABELS>
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
const DEFAULT_LABELS = {
|
|
24
|
+
tourTitle: "New to {page}?",
|
|
25
|
+
tourText: "A one-minute look at what you can do here.",
|
|
26
|
+
assignedTitle: "You were asked to look at {page}",
|
|
27
|
+
assignedText: "A short tour of this page, about a minute.",
|
|
28
|
+
newsTitle: "What changed: {page}",
|
|
29
|
+
show: "Show me",
|
|
30
|
+
notNow: "Not now",
|
|
31
|
+
never: "Don't offer again",
|
|
32
|
+
gotIt: "Got it",
|
|
33
|
+
noThanks: "No thanks",
|
|
34
|
+
}
|
|
35
|
+
const labels = useLabels("hints", DEFAULT_LABELS, () => props.labels)
|
|
36
|
+
|
|
37
|
+
provideHintSystem(props.system)
|
|
38
|
+
|
|
39
|
+
const broken = ref(false)
|
|
40
|
+
onErrorCaptured((error) => {
|
|
41
|
+
if (!broken.value) console.warn("[LpHints] disabled after an error:", error)
|
|
42
|
+
broken.value = true
|
|
43
|
+
props.system.stop()
|
|
44
|
+
return false
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
onMounted(() => {
|
|
48
|
+
try {
|
|
49
|
+
props.system.start()
|
|
50
|
+
if (!props.system.state.loaded) void props.system.load()
|
|
51
|
+
} catch (error) {
|
|
52
|
+
broken.value = true
|
|
53
|
+
console.warn("[LpHints] disabled after an error:", error)
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
onBeforeUnmount(() => props.system.stop())
|
|
57
|
+
|
|
58
|
+
const state = props.system.state
|
|
59
|
+
const fresh = computed(() => unref(props.system.fresh))
|
|
60
|
+
const notes = computed(() => unref(props.system.notes))
|
|
61
|
+
watch(() => [state.tour.open, state.tour.step] as const, ([open, step]) => open && props.system.tourShown(step))
|
|
62
|
+
|
|
63
|
+
const offer = computed(() => state.offer)
|
|
64
|
+
const page = computed(() => (offer.value ? props.system.pageTitle(offer.value.page) : ""))
|
|
65
|
+
const fill = (text: string) => text.replace("{page}", page.value)
|
|
66
|
+
|
|
67
|
+
const title = computed(() => {
|
|
68
|
+
const o = offer.value
|
|
69
|
+
if (!o) return ""
|
|
70
|
+
if (o.kind === "nudge") return notes.value[o.nudge.feature]?.title ?? ""
|
|
71
|
+
if (o.kind === "news") return fill(labels.value.newsTitle)
|
|
72
|
+
return fill(o.kind === "assigned" ? labels.value.assignedTitle : labels.value.tourTitle)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const description = computed(() => {
|
|
76
|
+
const o = offer.value
|
|
77
|
+
if (!o) return ""
|
|
78
|
+
if (o.kind === "nudge") return o.nudge.text
|
|
79
|
+
if (o.kind === "news") return o.items.map((n) => n.text).join(" ")
|
|
80
|
+
return o.kind === "assigned" ? labels.value.assignedText : labels.value.tourText
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const actions = computed<ActionBarAction[]>(() => {
|
|
84
|
+
const o = offer.value
|
|
85
|
+
if (!o) return []
|
|
86
|
+
const answer = (choice: "show" | "later" | "never") => () => props.system.answer(choice)
|
|
87
|
+
const show: ActionBarAction = { label: labels.value.show, icon: "lucide:sparkles", onClick: answer("show") }
|
|
88
|
+
if (o.kind === "nudge") return [{ label: labels.value.noThanks, variant: "ghost", onClick: answer("never") }, show]
|
|
89
|
+
if (o.kind === "news") return [{ label: labels.value.gotIt, variant: "ghost", onClick: answer("later") }, show]
|
|
90
|
+
return [
|
|
91
|
+
...(o.kind === "tour" ? [{ label: labels.value.never, variant: "ghost" as const, onClick: answer("never") }] : []),
|
|
92
|
+
{ label: labels.value.notNow, variant: "ghost", onClick: answer("later") },
|
|
93
|
+
show,
|
|
94
|
+
]
|
|
95
|
+
})
|
|
96
|
+
</script>
|
|
97
|
+
|
|
98
|
+
<template>
|
|
99
|
+
<template v-if="!broken">
|
|
100
|
+
<LpTour
|
|
101
|
+
v-model:open="state.tour.open"
|
|
102
|
+
v-model:step="state.tour.step"
|
|
103
|
+
:steps="state.tour.steps"
|
|
104
|
+
@finish="system.tourEnded(true)"
|
|
105
|
+
@dismiss="system.tourEnded(false)"
|
|
106
|
+
/>
|
|
107
|
+
<LpHintLayer :fresh="fresh" :notes="notes" @read="system.readFresh" />
|
|
108
|
+
<LpActionBar
|
|
109
|
+
:open="!!offer"
|
|
110
|
+
:title="title"
|
|
111
|
+
:description="description"
|
|
112
|
+
:icon="offer?.kind === 'news' ? 'lucide:gift' : 'lucide:compass'"
|
|
113
|
+
:actions="actions"
|
|
114
|
+
/>
|
|
115
|
+
</template>
|
|
116
|
+
</template>
|
|
@@ -125,9 +125,13 @@ const smooth = () => !prefersReducedMotion()
|
|
|
125
125
|
|
|
126
126
|
function resolve(raw: TourStep["target"]): Element | null {
|
|
127
127
|
if (!raw) return null
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
128
|
+
try {
|
|
129
|
+
if (typeof raw === "function") return raw()
|
|
130
|
+
if (typeof raw !== "string") return raw.isConnected ? raw : null
|
|
131
|
+
return hints.element(raw) ?? document.querySelector(raw)
|
|
132
|
+
} catch {
|
|
133
|
+
return null
|
|
134
|
+
}
|
|
131
135
|
}
|
|
132
136
|
|
|
133
137
|
function waitFor(raw: TourStep["target"], mine: number): Promise<Element | null> {
|
|
@@ -169,7 +173,13 @@ async function show(): Promise<void> {
|
|
|
169
173
|
leave()
|
|
170
174
|
const current = step.value
|
|
171
175
|
if (!current) return
|
|
172
|
-
|
|
176
|
+
try {
|
|
177
|
+
await current.enter?.({ onLeave: (fn) => leaving.push(fn), onEnd: (fn) => cleanups.push(fn) })
|
|
178
|
+
} catch (error) {
|
|
179
|
+
console.warn("[LpTour] step skipped, its enter() failed:", error)
|
|
180
|
+
if (mine === token) skip()
|
|
181
|
+
return
|
|
182
|
+
}
|
|
173
183
|
if (mine !== token) return
|
|
174
184
|
if (!current.target) {
|
|
175
185
|
target.value = null
|
|
@@ -214,8 +224,18 @@ async function perform() {
|
|
|
214
224
|
setTimeout(next, TRY_SETTLE_MS)
|
|
215
225
|
}
|
|
216
226
|
|
|
227
|
+
function runAll(list: (() => void)[]) {
|
|
228
|
+
for (const cleanup of list.splice(0).reverse()) {
|
|
229
|
+
try {
|
|
230
|
+
cleanup()
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.warn("[LpTour] cleanup failed:", error)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
217
237
|
function leave() {
|
|
218
|
-
|
|
238
|
+
runAll(leaving)
|
|
219
239
|
}
|
|
220
240
|
|
|
221
241
|
function end() {
|
|
@@ -225,7 +245,7 @@ function end() {
|
|
|
225
245
|
frame = 0
|
|
226
246
|
target.value = null
|
|
227
247
|
box.value = null
|
|
228
|
-
|
|
248
|
+
runAll(cleanups)
|
|
229
249
|
}
|
|
230
250
|
|
|
231
251
|
function finish(): void {
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
import { computed, inject, provide, reactive, watch, type ComputedRef, type InjectionKey, type WatchStopHandle } from "vue"
|
|
2
|
+
import type { HintNote } from "../components/LpHintLayer.vue"
|
|
3
|
+
import type { TourContext, TourStep } from "../components/LpTour.vue"
|
|
4
|
+
import { anyModalOpen } from "../components/modalDepth"
|
|
5
|
+
import { hints } from "./useHints"
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* Onboarding that does not nag: a first-visit tour offer, quiet "new" dots,
|
|
9
|
+
* "what changed" after an update, and nudges toward unused features — all
|
|
10
|
+
* spending one shared attention budget.
|
|
11
|
+
*
|
|
12
|
+
* The app describes its features once (each tied to the `v-hint` element that
|
|
13
|
+
* represents it) and says which page is current; the system decides what to
|
|
14
|
+
* show and when, remembers what each person has seen, and renders through
|
|
15
|
+
* <LpHints>. Everything below is policy the app would otherwise re-derive:
|
|
16
|
+
*
|
|
17
|
+
* budget — at most one interruption (offer, changes, nudge) per session,
|
|
18
|
+
* never over a dialog, while typing, right after navigation, or
|
|
19
|
+
* when the user arrived with intent (palette, notification);
|
|
20
|
+
* memory — per key, a state that only climbs (seen → explained → used),
|
|
21
|
+
* so what was used is never explained again;
|
|
22
|
+
* fresh — a feature becomes "new" only after the page is known: when a
|
|
23
|
+
* page is first met, everything on it is quietly baselined, so a
|
|
24
|
+
* rollout does not light every control for existing users;
|
|
25
|
+
* news — "what changed" is for people who knew the page before: a
|
|
26
|
+
* newcomer's first visit marks the page's news as read, while a
|
|
27
|
+
* returning user (recognised by `known`) still gets it;
|
|
28
|
+
* retries — a declined tour is offered again at most `offersMax` times,
|
|
29
|
+
* `offerAgain` apart; a nudge waits `nudgeAgain` between showings.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export interface HintFeature {
|
|
33
|
+
page: string
|
|
34
|
+
title: string
|
|
35
|
+
text: string
|
|
36
|
+
order?: number
|
|
37
|
+
keys?: string
|
|
38
|
+
try?: boolean
|
|
39
|
+
side?: TourStep["side"]
|
|
40
|
+
/** Bump to announce a changed feature again: it reads as new to everyone. */
|
|
41
|
+
rev?: number
|
|
42
|
+
/** Keeps its dot until explained, even after "not now". */
|
|
43
|
+
required?: boolean
|
|
44
|
+
/**
|
|
45
|
+
* Rendered only in some states (data-dependent, behind a filter). Its first
|
|
46
|
+
* appearance is not news, so it is never marked new for merely showing up;
|
|
47
|
+
* a `rev` bump still announces it.
|
|
48
|
+
*/
|
|
49
|
+
conditional?: boolean
|
|
50
|
+
/** When the `v-hint` id is not the element to light (a column header, an opened menu). */
|
|
51
|
+
target?: () => Element | null
|
|
52
|
+
enter?: (context: TourContext) => void | Promise<void>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface HintPage {
|
|
56
|
+
title: string
|
|
57
|
+
/**
|
|
58
|
+
* A localStorage key prefix that proves the user worked on this page before
|
|
59
|
+
* the system existed (a remembered table layout). Such a page is treated as
|
|
60
|
+
* known on first sight: no tour offer, no dots for what was always there.
|
|
61
|
+
*/
|
|
62
|
+
known?: string
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface HintNews {
|
|
66
|
+
id: string
|
|
67
|
+
date: string
|
|
68
|
+
page: string
|
|
69
|
+
text: string
|
|
70
|
+
feature?: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface HintNudge {
|
|
74
|
+
id: string
|
|
75
|
+
feature: string
|
|
76
|
+
text: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type HintLevel = "seen" | "explained" | "used"
|
|
80
|
+
|
|
81
|
+
export interface HintRecord {
|
|
82
|
+
state: HintLevel
|
|
83
|
+
dismissed: boolean
|
|
84
|
+
count: number
|
|
85
|
+
assigned?: boolean
|
|
86
|
+
updatedAt: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface HintDelta {
|
|
90
|
+
key: string
|
|
91
|
+
state?: HintLevel
|
|
92
|
+
dismissed?: boolean
|
|
93
|
+
add?: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface HintStorage {
|
|
97
|
+
load(): Promise<Record<string, HintRecord>> | Record<string, HintRecord>
|
|
98
|
+
save(deltas: HintDelta[]): Promise<void> | void
|
|
99
|
+
/** Last chance on page hide (sendBeacon); falls back to `save`. */
|
|
100
|
+
saveOnLeave?(deltas: HintDelta[]): void
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface HintTiming {
|
|
104
|
+
settle: number
|
|
105
|
+
offerAgain: number
|
|
106
|
+
offersMax: number
|
|
107
|
+
freshLimit: number
|
|
108
|
+
nudgeAgain: number
|
|
109
|
+
flush: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface HintSystemOptions {
|
|
113
|
+
features: Record<string, HintFeature>
|
|
114
|
+
pages?: Record<string, HintPage>
|
|
115
|
+
news?: HintNews[]
|
|
116
|
+
storage?: HintStorage
|
|
117
|
+
/** The current page id, read reactively (map your route to it). */
|
|
118
|
+
page: () => string
|
|
119
|
+
sessionKey?: string
|
|
120
|
+
timing?: Partial<HintTiming>
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export type HintOffer =
|
|
124
|
+
| { kind: "tour" | "assigned"; page: string }
|
|
125
|
+
| { kind: "news"; page: string; items: HintNews[] }
|
|
126
|
+
| { kind: "nudge"; page: string; nudge: HintNudge }
|
|
127
|
+
|
|
128
|
+
const RANK: Record<HintLevel, number> = { seen: 0, explained: 1, used: 2 }
|
|
129
|
+
const DAY = 24 * 3600 * 1000
|
|
130
|
+
const DEFAULT_TIMING: HintTiming = {
|
|
131
|
+
settle: 2500,
|
|
132
|
+
offerAgain: 3 * DAY,
|
|
133
|
+
offersMax: 2,
|
|
134
|
+
freshLimit: 3,
|
|
135
|
+
nudgeAgain: 7 * DAY,
|
|
136
|
+
flush: 2000,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Records in localStorage: for anonymous sessions and apps without a server. */
|
|
140
|
+
export function localHintStorage(key = "lp-hints"): HintStorage {
|
|
141
|
+
const read = (): Record<string, HintRecord> => {
|
|
142
|
+
try {
|
|
143
|
+
return JSON.parse(localStorage.getItem(key) || "{}") as Record<string, HintRecord>
|
|
144
|
+
} catch {
|
|
145
|
+
return {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
load: read,
|
|
150
|
+
save(deltas) {
|
|
151
|
+
const all = read()
|
|
152
|
+
for (const d of deltas) all[d.key] = merge(all[d.key], d)
|
|
153
|
+
try {
|
|
154
|
+
localStorage.setItem(key, JSON.stringify(all))
|
|
155
|
+
} catch {
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function merge(record: HintRecord | undefined, delta: Omit<HintDelta, "key">): HintRecord {
|
|
163
|
+
const base = record ?? { state: "seen" as HintLevel, dismissed: false, count: 0, updatedAt: "" }
|
|
164
|
+
return {
|
|
165
|
+
...base,
|
|
166
|
+
state: delta.state && RANK[delta.state] > RANK[base.state] ? delta.state : base.state,
|
|
167
|
+
dismissed: delta.dismissed ?? base.dismissed,
|
|
168
|
+
count: base.count + (delta.add ?? 0),
|
|
169
|
+
updatedAt: new Date().toISOString(),
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Record keys, for apps that read or assign records directly (a "who knows what" view). */
|
|
174
|
+
export const hintKey = {
|
|
175
|
+
feature: (id: string, rev = 1) => `f:${id}:${rev}`,
|
|
176
|
+
page: (page: string) => `t:${page}`,
|
|
177
|
+
news: (id: string) => `c:${id}`,
|
|
178
|
+
nudge: (id: string) => `n:${id}`,
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface HintSummary {
|
|
182
|
+
tour: "done" | "assigned" | "declined" | "offered" | "never"
|
|
183
|
+
offers: number
|
|
184
|
+
known: number
|
|
185
|
+
total: number
|
|
186
|
+
/** Titles of `required` features this person has not been shown. */
|
|
187
|
+
missing: string[]
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** What one person knows of a page, from their records: for a manager's view. */
|
|
191
|
+
export function hintSummary(
|
|
192
|
+
records: Map<string, Pick<HintRecord, "state" | "dismissed" | "count" | "assigned">>,
|
|
193
|
+
features: Record<string, HintFeature>,
|
|
194
|
+
page: string,
|
|
195
|
+
): HintSummary {
|
|
196
|
+
const own = Object.entries(features).filter(([, f]) => f.page === page)
|
|
197
|
+
const knows = (id: string, f: HintFeature) => RANK[records.get(hintKey.feature(id, f.rev))?.state ?? "seen"] >= RANK.explained
|
|
198
|
+
const tour = records.get(hintKey.page(page))
|
|
199
|
+
return {
|
|
200
|
+
tour: tour && RANK[tour.state] >= RANK.explained
|
|
201
|
+
? "done"
|
|
202
|
+
: tour?.assigned ? "assigned" : tour?.dismissed ? "declined" : tour ? "offered" : "never",
|
|
203
|
+
offers: tour?.count ?? 0,
|
|
204
|
+
known: own.filter(([id, f]) => knows(id, f)).length,
|
|
205
|
+
total: own.length,
|
|
206
|
+
missing: own.filter(([id, f]) => f.required && !knows(id, f)).map(([, f]) => f.title),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function typing(): boolean {
|
|
211
|
+
const el = document.activeElement
|
|
212
|
+
return el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || (el instanceof HTMLElement && el.isContentEditable)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function createHintSystem(options: HintSystemOptions) {
|
|
216
|
+
const timing = { ...DEFAULT_TIMING, ...options.timing }
|
|
217
|
+
const features = options.features
|
|
218
|
+
const pages = options.pages ?? {}
|
|
219
|
+
const news = options.news ?? []
|
|
220
|
+
const storage = options.storage ?? localHintStorage()
|
|
221
|
+
const sessionKey = options.sessionKey ?? "lp-hints:interrupted"
|
|
222
|
+
|
|
223
|
+
const state = reactive({
|
|
224
|
+
records: new Map<string, HintRecord>(),
|
|
225
|
+
loaded: false,
|
|
226
|
+
page: "",
|
|
227
|
+
tour: { open: false, step: 0, steps: [] as TourStep[], ids: [] as string[], page: "" },
|
|
228
|
+
offer: null as HintOffer | null,
|
|
229
|
+
candidate: null as (HintNudge & { page: string }) | null,
|
|
230
|
+
settledAt: 0,
|
|
231
|
+
intent: false,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const pending = new Map<string, HintDelta>()
|
|
235
|
+
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
|
236
|
+
|
|
237
|
+
const featureKey = (id: string) => hintKey.feature(id, features[id]?.rev)
|
|
238
|
+
const pageKey = hintKey.page
|
|
239
|
+
const newsKey = hintKey.news
|
|
240
|
+
const nudgeKey = hintKey.nudge
|
|
241
|
+
|
|
242
|
+
function interrupted(): boolean {
|
|
243
|
+
try {
|
|
244
|
+
return sessionStorage.getItem(sessionKey) === "1"
|
|
245
|
+
} catch {
|
|
246
|
+
return false
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function spend(): void {
|
|
251
|
+
try {
|
|
252
|
+
sessionStorage.setItem(sessionKey, "1")
|
|
253
|
+
} catch {
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function flush(): Promise<void> {
|
|
259
|
+
clearTimeout(flushTimer)
|
|
260
|
+
flushTimer = undefined
|
|
261
|
+
if (!pending.size) return
|
|
262
|
+
const deltas = [...pending.values()]
|
|
263
|
+
pending.clear()
|
|
264
|
+
try {
|
|
265
|
+
await storage.save(deltas)
|
|
266
|
+
} catch {
|
|
267
|
+
for (const d of deltas) if (!pending.has(d.key)) pending.set(d.key, d)
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function flushOnLeave(): void {
|
|
272
|
+
if (!pending.size) return
|
|
273
|
+
const deltas = [...pending.values()]
|
|
274
|
+
pending.clear()
|
|
275
|
+
if (storage.saveOnLeave) storage.saveOnLeave(deltas)
|
|
276
|
+
else void storage.save(deltas)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function record(key: string, delta: Omit<HintDelta, "key">): void {
|
|
280
|
+
state.records.set(key, merge(state.records.get(key), delta))
|
|
281
|
+
const queued = pending.get(key)
|
|
282
|
+
pending.set(key, {
|
|
283
|
+
key,
|
|
284
|
+
state: delta.state ?? queued?.state,
|
|
285
|
+
dismissed: delta.dismissed ?? queued?.dismissed,
|
|
286
|
+
add: (queued?.add ?? 0) + (delta.add ?? 0),
|
|
287
|
+
})
|
|
288
|
+
flushTimer ??= setTimeout(() => void flush(), timing.flush)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const present = (page: string) => Object.keys(features).filter((id) => features[id]!.page === page && hints.present(id))
|
|
292
|
+
|
|
293
|
+
function knownBefore(page: string): boolean {
|
|
294
|
+
const marker = pages[page]?.known
|
|
295
|
+
if (!marker) return false
|
|
296
|
+
try {
|
|
297
|
+
return Object.keys(localStorage).some((key) => key.startsWith(marker))
|
|
298
|
+
} catch {
|
|
299
|
+
return false
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function baseline(page: string): void {
|
|
304
|
+
for (const id of present(page)) if (!state.records.has(featureKey(id))) record(featureKey(id), { state: "seen" })
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const fresh: ComputedRef<string[]> = computed(() => {
|
|
308
|
+
const page = state.page
|
|
309
|
+
if (!page || !state.loaded || state.tour.open || !state.records.has(pageKey(page))) return []
|
|
310
|
+
return present(page)
|
|
311
|
+
.filter((id) => {
|
|
312
|
+
const f = features[id]!
|
|
313
|
+
const rec = state.records.get(featureKey(id))
|
|
314
|
+
if (!rec) return !f.conditional || (f.rev ?? 1) > 1
|
|
315
|
+
return !!f.required && RANK[rec.state] < RANK.explained && !rec.dismissed
|
|
316
|
+
})
|
|
317
|
+
.sort((a, b) => Number(!!features[b]!.required) - Number(!!features[a]!.required) || (features[a]!.order ?? 0) - (features[b]!.order ?? 0))
|
|
318
|
+
.slice(0, timing.freshLimit)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const notes: ComputedRef<Record<string, HintNote>> = computed(() =>
|
|
322
|
+
Object.fromEntries(Object.entries(features).map(([id, f]) => [id, { title: f.title, text: f.text }])),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
const hasTour = (page = state.page) => Object.values(features).some((f) => f.page === page)
|
|
326
|
+
|
|
327
|
+
function startTour(page = state.page, only?: string[]): void {
|
|
328
|
+
const entries = Object.entries(features)
|
|
329
|
+
.filter(([id, f]) => f.page === page && (!only || only.includes(id)) && (!!f.target || !!f.enter || hints.present(id)))
|
|
330
|
+
.sort(([, a], [, b]) => (a.order ?? 0) - (b.order ?? 0))
|
|
331
|
+
if (!entries.length) return
|
|
332
|
+
state.offer = null
|
|
333
|
+
Object.assign(state.tour, {
|
|
334
|
+
open: true,
|
|
335
|
+
step: 0,
|
|
336
|
+
page,
|
|
337
|
+
ids: entries.map(([id]) => id),
|
|
338
|
+
steps: entries.map(([id, f]) => ({
|
|
339
|
+
target: f.target ?? id,
|
|
340
|
+
title: f.title,
|
|
341
|
+
text: f.text,
|
|
342
|
+
keys: f.keys,
|
|
343
|
+
try: f.try,
|
|
344
|
+
side: f.side,
|
|
345
|
+
enter: f.enter,
|
|
346
|
+
})),
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const unread = (page?: string) =>
|
|
351
|
+
news.filter((n) => (!page || n.page === page) && !state.records.has(newsKey(n.id)) && (!page || !n.feature || hints.present(n.feature)))
|
|
352
|
+
|
|
353
|
+
function readNews(page: string): void {
|
|
354
|
+
for (const n of unread(page)) record(newsKey(n.id), { state: "explained" })
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function considerOffer(): void {
|
|
358
|
+
const page = state.page
|
|
359
|
+
const ready = state.loaded && !state.tour.open && !state.offer && !anyModalOpen() && !typing()
|
|
360
|
+
&& Date.now() - state.settledAt > timing.settle
|
|
361
|
+
if (!page || !ready) return
|
|
362
|
+
const rec = state.records.get(pageKey(page))
|
|
363
|
+
if (rec?.assigned && RANK[rec.state] < RANK.explained && hasTour(page)) {
|
|
364
|
+
state.offer = { kind: "assigned", page }
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
if (state.intent || interrupted()) return
|
|
368
|
+
if (!rec && knownBefore(page)) {
|
|
369
|
+
record(pageKey(page), { state: "seen" })
|
|
370
|
+
baseline(page)
|
|
371
|
+
} else if (
|
|
372
|
+
!rec
|
|
373
|
+
|| (!rec.dismissed && RANK[rec.state] < RANK.explained && rec.count < timing.offersMax
|
|
374
|
+
&& Date.now() - Date.parse(rec.updatedAt || "0") > timing.offerAgain)
|
|
375
|
+
) {
|
|
376
|
+
if (!hasTour(page)) return
|
|
377
|
+
spend()
|
|
378
|
+
if (!rec) {
|
|
379
|
+
record(pageKey(page), { state: "seen" })
|
|
380
|
+
baseline(page)
|
|
381
|
+
readNews(page)
|
|
382
|
+
}
|
|
383
|
+
state.offer = { kind: "tour", page }
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
const items = unread(page)
|
|
387
|
+
if (items.length) {
|
|
388
|
+
spend()
|
|
389
|
+
state.offer = { kind: "news", page, items: items.slice(0, 3) }
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
const n = state.candidate
|
|
393
|
+
if (!n || n.page !== page) return
|
|
394
|
+
const seen = state.records.get(nudgeKey(n.id))
|
|
395
|
+
if (seen?.dismissed || state.records.get(featureKey(n.feature))?.state === "used" || !hints.present(n.feature)) return
|
|
396
|
+
if (seen && Date.now() - Date.parse(seen.updatedAt) < timing.nudgeAgain) return
|
|
397
|
+
state.candidate = null
|
|
398
|
+
spend()
|
|
399
|
+
record(nudgeKey(n.id), { add: 1 })
|
|
400
|
+
state.offer = { kind: "nudge", page, nudge: n }
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let stops: (() => void)[] = []
|
|
404
|
+
|
|
405
|
+
function start(): void {
|
|
406
|
+
if (stops.length) return
|
|
407
|
+
const stopPage: WatchStopHandle = watch(
|
|
408
|
+
options.page,
|
|
409
|
+
(page) => {
|
|
410
|
+
state.page = page
|
|
411
|
+
state.settledAt = Date.now()
|
|
412
|
+
if (state.offer && state.offer.page !== page) state.offer = null
|
|
413
|
+
if (state.candidate && state.candidate.page !== page) state.candidate = null
|
|
414
|
+
setTimeout(() => (state.intent = false), timing.settle * 2)
|
|
415
|
+
},
|
|
416
|
+
{ immediate: true },
|
|
417
|
+
)
|
|
418
|
+
const poll = setInterval(() => {
|
|
419
|
+
try {
|
|
420
|
+
considerOffer()
|
|
421
|
+
} catch (error) {
|
|
422
|
+
console.warn("[hints] disabled after an error:", error)
|
|
423
|
+
stop()
|
|
424
|
+
}
|
|
425
|
+
}, 1000)
|
|
426
|
+
window.addEventListener("pagehide", flushOnLeave)
|
|
427
|
+
stops = [
|
|
428
|
+
stopPage,
|
|
429
|
+
hints.on("used", (id) => {
|
|
430
|
+
if (features[id]) record(featureKey(id), { state: "used", add: 1 })
|
|
431
|
+
}),
|
|
432
|
+
() => clearInterval(poll),
|
|
433
|
+
() => window.removeEventListener("pagehide", flushOnLeave),
|
|
434
|
+
]
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function stop(): void {
|
|
438
|
+
for (const s of stops.splice(0)) s()
|
|
439
|
+
flushOnLeave()
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return {
|
|
443
|
+
state,
|
|
444
|
+
fresh,
|
|
445
|
+
notes,
|
|
446
|
+
start,
|
|
447
|
+
stop,
|
|
448
|
+
hasTour,
|
|
449
|
+
startTour,
|
|
450
|
+
async load(): Promise<void> {
|
|
451
|
+
state.records.clear()
|
|
452
|
+
try {
|
|
453
|
+
for (const [key, rec] of Object.entries(await storage.load())) state.records.set(key, rec)
|
|
454
|
+
state.loaded = true
|
|
455
|
+
} catch {
|
|
456
|
+
state.loaded = false
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
/** Ask for a nudge; it shows when the budget and the page allow. */
|
|
460
|
+
nudge(n: HintNudge): void {
|
|
461
|
+
state.candidate = { ...n, page: state.page }
|
|
462
|
+
},
|
|
463
|
+
/** The user navigated on purpose (palette, notification): do not interrupt this arrival. */
|
|
464
|
+
markIntent(): void {
|
|
465
|
+
state.intent = true
|
|
466
|
+
},
|
|
467
|
+
unreadNews: (page?: string) => unread(page),
|
|
468
|
+
readNews,
|
|
469
|
+
pageTitle: (page: string) => pages[page]?.title ?? page,
|
|
470
|
+
readFresh(id: string): void {
|
|
471
|
+
record(featureKey(id), { state: "explained" })
|
|
472
|
+
},
|
|
473
|
+
tourShown(index: number): void {
|
|
474
|
+
const id = state.tour.ids[index]
|
|
475
|
+
if (id) record(featureKey(id), { state: "explained" })
|
|
476
|
+
},
|
|
477
|
+
tourEnded(finished: boolean): void {
|
|
478
|
+
record(pageKey(state.tour.page), finished ? { state: "explained" } : { add: 1 })
|
|
479
|
+
baseline(state.tour.page)
|
|
480
|
+
},
|
|
481
|
+
answer(choice: "show" | "later" | "never"): void {
|
|
482
|
+
const offer = state.offer
|
|
483
|
+
state.offer = null
|
|
484
|
+
if (!offer) return
|
|
485
|
+
if (offer.kind === "news") {
|
|
486
|
+
const ids = offer.items.map((n) => n.feature).filter((id): id is string => !!id)
|
|
487
|
+
readNews(offer.page)
|
|
488
|
+
if (choice === "show" && ids.length) startTour(offer.page, ids)
|
|
489
|
+
} else if (offer.kind === "nudge") {
|
|
490
|
+
if (choice === "show") startTour(features[offer.nudge.feature]!.page, [offer.nudge.feature])
|
|
491
|
+
else record(nudgeKey(offer.nudge.id), { dismissed: true })
|
|
492
|
+
} else if (choice === "show") {
|
|
493
|
+
startTour(offer.page)
|
|
494
|
+
} else {
|
|
495
|
+
record(pageKey(offer.page), { add: 1, dismissed: choice === "never" })
|
|
496
|
+
baseline(offer.page)
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export type HintSystem = ReturnType<typeof createHintSystem>
|
|
503
|
+
|
|
504
|
+
const HINT_SYSTEM: InjectionKey<HintSystem> = Symbol.for("lp-hint-system")
|
|
505
|
+
|
|
506
|
+
export function provideHintSystem(system: HintSystem): void {
|
|
507
|
+
provide(HINT_SYSTEM, system)
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function useHintSystem(): HintSystem | null {
|
|
511
|
+
return inject(HINT_SYSTEM, null)
|
|
512
|
+
}
|