@tollbooth-dpyc/web 1.7.1 → 1.8.0
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 +42 -0
- package/dist/config.d.ts +4 -1
- package/dist/config.js +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/react/CouponsPanel.js +1 -1
- package/dist/react/NostrProfilePanel.js +1 -1
- package/dist/react/NpubGate.js +2 -2
- package/dist/react/SessionKeyClaim.js +2 -2
- package/dist/react/SignInLinks.js +1 -1
- package/dist/react/SignInSteps.js +1 -1
- package/dist/react/UsageSummary.js +1 -1
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +2 -0
- package/dist/react/useTour.d.ts +46 -0
- package/dist/react/useTour.js +128 -0
- package/dist/tour/idle.d.ts +19 -0
- package/dist/tour/idle.js +24 -0
- package/dist/tour/lint.d.ts +18 -0
- package/dist/tour/lint.js +47 -0
- package/dist/tour/run.d.ts +47 -0
- package/dist/tour/run.js +163 -0
- package/dist/tour/state.d.ts +34 -0
- package/dist/tour/state.js +54 -0
- package/dist/tour/tours.d.ts +28 -0
- package/dist/tour/tours.js +54 -0
- package/dist/tour/types.d.ts +35 -0
- package/dist/tour/types.js +10 -0
- package/dist/tourPrefs.d.ts +21 -0
- package/dist/tourPrefs.js +70 -0
- package/package.json +7 -2
- package/tour.css +103 -0
package/README.md
CHANGED
|
@@ -261,6 +261,48 @@ with the step under way lit from the gate's real stage, and `SignInLinks` —
|
|
|
261
261
|
headless and exported on their own; `classNames.steps` / `classNames.links`
|
|
262
262
|
restyle them inside the gate. `steps={false}` / `links={false}` remove them.
|
|
263
263
|
|
|
264
|
+
### Guided tour
|
|
265
|
+
|
|
266
|
+
A page can walk itself through: small popovers in turn, each pointing at a
|
|
267
|
+
control and saying what it is for. The package carries the mechanics — the
|
|
268
|
+
popover (driver.js), the preference, who sees it, the offer, the copy lint
|
|
269
|
+
and the two tours every site shares (the sign-in gate and the account page's
|
|
270
|
+
panels). A site supplies its `data-tour` names and its own words.
|
|
271
|
+
|
|
272
|
+
```tsx
|
|
273
|
+
import { signInTour, accountTour, type Tour } from "@tollbooth-dpyc/web";
|
|
274
|
+
import { TourProvider, useTour, useTourSwitch, useTourReplay, tourTarget } from "@tollbooth-dpyc/web/react";
|
|
275
|
+
|
|
276
|
+
const TOURS: Record<string, Tour> = {
|
|
277
|
+
signin: signInTour(),
|
|
278
|
+
account: accountTour("account", [{ target: "account.sound", title: "Sound", says: "The hive hums. Switch it off here." }]),
|
|
279
|
+
play: { page: "play", steps: [
|
|
280
|
+
{ target: "play.practice", title: "Practice", says: "A round against the house, free. Tap it to learn the moves." },
|
|
281
|
+
]},
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
<TourProvider tours={TOURS}>…</TourProvider> // once, around the app
|
|
285
|
+
useTour("play", { actions: { "open-lobby": () => setMode("live") } }); // once per page
|
|
286
|
+
<button {...tourTarget("play.practice")}>Practice</button>
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Who sees it: a new device starts with the live tutorial on; a device that
|
|
290
|
+
already holds a patron npub starts with it off. On a page that is due, after
|
|
291
|
+
ten seconds untouched (`configureTollbooth({ tourIdleMs })`, or
|
|
292
|
+
`TourProvider idleMs`), a centred popover asks whether they would like a tour;
|
|
293
|
+
Yes starts it, No marks the page. A step whose control is absent is skipped;
|
|
294
|
+
a step with `before` has the page open something first, by the name the page
|
|
295
|
+
registered in `actions`. "Stop the tutorial" sits in every popover. The site's
|
|
296
|
+
own switch and replay button take their mechanics from `useTourSwitch()` and
|
|
297
|
+
`useTourReplay()`.
|
|
298
|
+
|
|
299
|
+
Import `@tollbooth-dpyc/web/tour.css` beside `theme.css`; the popover takes the
|
|
300
|
+
site's `--tb-*` tokens, and `.tb-tour` can be restyled further. The package's
|
|
301
|
+
markup carries `tb.gate.*` and `tb.account.*` names (`TB_TARGETS`); the two
|
|
302
|
+
shared tours point at them. `lintTours(TOURS)` in a site's test holds the copy
|
|
303
|
+
to the voice: one or two short sentences saying what a thing is. A sentence
|
|
304
|
+
carrying "not", "no", "never", "instead of" or "rather than" fails it.
|
|
305
|
+
|
|
264
306
|
### Debug panel
|
|
265
307
|
|
|
266
308
|
`callTool` logs to a shared, scrubbed ring buffer; `DebugPanel` shows it as a
|
package/dist/config.d.ts
CHANGED
|
@@ -29,11 +29,14 @@ export interface TollboothConfig {
|
|
|
29
29
|
quietTools?: string[];
|
|
30
30
|
/** Avatar glyphs in this site's voice. Defaults to a general set. */
|
|
31
31
|
avatarChoices?: string[];
|
|
32
|
+
/** How long a page sits untouched before its guided tour is offered, in ms. Defaults to 10 s. */
|
|
33
|
+
tourIdleMs?: number;
|
|
32
34
|
}
|
|
33
|
-
interface Resolved extends Required<Omit<TollboothConfig, "extraBootstrapTools" | "quietTools" | "avatarChoices">> {
|
|
35
|
+
interface Resolved extends Required<Omit<TollboothConfig, "extraBootstrapTools" | "quietTools" | "avatarChoices" | "tourIdleMs">> {
|
|
34
36
|
extraBootstrapTools: string[];
|
|
35
37
|
quietTools: string[];
|
|
36
38
|
avatarChoices?: string[];
|
|
39
|
+
tourIdleMs?: number;
|
|
37
40
|
}
|
|
38
41
|
export declare function configureTollbooth(config: TollboothConfig): void;
|
|
39
42
|
export declare function tollboothConfig(): Resolved;
|
package/dist/config.js
CHANGED
|
@@ -20,6 +20,7 @@ export function configureTollbooth(config) {
|
|
|
20
20
|
extraBootstrapTools: config.extraBootstrapTools ?? [],
|
|
21
21
|
quietTools: config.quietTools ?? [],
|
|
22
22
|
avatarChoices: config.avatarChoices,
|
|
23
|
+
tourIdleMs: config.tourIdleMs,
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
export function tollboothConfig() {
|
package/dist/index.d.ts
CHANGED
|
@@ -22,3 +22,9 @@ export * from "./funding.ts";
|
|
|
22
22
|
export { errorReport } from "./errorReport.ts";
|
|
23
23
|
export { loadQuotes, peekQuotes, shuffle, validQuotes, type Quote } from "./quotes.ts";
|
|
24
24
|
export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, type DebugEntry, type DebugLogOptions, type DebugSeverity, } from "./debugLog.ts";
|
|
25
|
+
export type { Tour, TourActions, TourStep } from "./tour/types.ts";
|
|
26
|
+
export { fromStored as tourPrefsFromStored, markToured, replayAll, replayPage, shouldRun, stopAll, TOUR_DEFAULTS, type TourPrefs, } from "./tour/state.ts";
|
|
27
|
+
export { ACTIVITY_EVENTS, DEFAULT_IDLE_MS, idleThreshold, offerDue } from "./tour/idle.ts";
|
|
28
|
+
export { lintStep, lintTour, lintTours, MAX_SAYS, MAX_SENTENCES, MAX_TITLE } from "./tour/lint.ts";
|
|
29
|
+
export { accountPanelsSteps, accountTour, signInTour, TB_TARGETS } from "./tour/tours.ts";
|
|
30
|
+
export { onTourChange, readTourPrefs, settleTourPrefs, tourStorageKey, writeTourPrefs } from "./tourPrefs.ts";
|
package/dist/index.js
CHANGED
|
@@ -21,3 +21,8 @@ export * from "./funding.js";
|
|
|
21
21
|
export { errorReport } from "./errorReport.js";
|
|
22
22
|
export { loadQuotes, peekQuotes, shuffle, validQuotes } from "./quotes.js";
|
|
23
23
|
export { captureGlobalErrors, clearDebug, configureDebugLog, debugEntries, debugLogText, debugPush, debugSeverity, onDebug, redact, } from "./debugLog.js";
|
|
24
|
+
export { fromStored as tourPrefsFromStored, markToured, replayAll, replayPage, shouldRun, stopAll, TOUR_DEFAULTS, } from "./tour/state.js";
|
|
25
|
+
export { ACTIVITY_EVENTS, DEFAULT_IDLE_MS, idleThreshold, offerDue } from "./tour/idle.js";
|
|
26
|
+
export { lintStep, lintTour, lintTours, MAX_SAYS, MAX_SENTENCES, MAX_TITLE } from "./tour/lint.js";
|
|
27
|
+
export { accountPanelsSteps, accountTour, signInTour, TB_TARGETS } from "./tour/tours.js";
|
|
28
|
+
export { onTourChange, readTourPrefs, settleTourPrefs, tourStorageKey, writeTourPrefs } from "./tourPrefs.js";
|
|
@@ -84,7 +84,7 @@ export default function CouponsPanel({ heading = "My coupons", intro = DEFAULT_I
|
|
|
84
84
|
setMsg({ ok: false, text: e.message });
|
|
85
85
|
}
|
|
86
86
|
}
|
|
87
|
-
return (_jsxs("section", { className: c.root, children: [heading && _jsx("div", { className: c.heading, children: heading }), intro && _jsx("p", { className: c.intro, children: intro }), formHeading && _jsx("div", { className: c.subheading, children: formHeading }), _jsxs("div", { className: c.form, children: [_jsx("input", { type: "text", value: code, onChange: (e) => setCode(e.target.value.toUpperCase()), onKeyDown: (e) => {
|
|
87
|
+
return (_jsxs("section", { className: c.root, "data-tour": "tb.account.coupons", children: [heading && _jsx("div", { className: c.heading, children: heading }), intro && _jsx("p", { className: c.intro, children: intro }), formHeading && _jsx("div", { className: c.subheading, children: formHeading }), _jsxs("div", { className: c.form, children: [_jsx("input", { type: "text", value: code, onChange: (e) => setCode(e.target.value.toUpperCase()), onKeyDown: (e) => {
|
|
88
88
|
if (e.key === "Enter")
|
|
89
89
|
void redeem();
|
|
90
90
|
}, placeholder: placeholder, "aria-label": "Coupon code", autoCapitalize: "characters", autoCorrect: "off", spellCheck: false, disabled: redeeming, className: c.input }), _jsx("button", { type: "button", onClick: () => void redeem(), disabled: redeeming || !code.trim(), className: c.primary ?? c.chip, children: redeeming ? "Redeeming…" : redeemLabel })] }), msg && (_jsx("p", { role: "status", className: cx(c.message, msg.ok ? c.ok : c.error), children: msg.text })), loading ? (_jsx("p", { className: c.loading, children: "Loading\u2026" })) : loadError ? (_jsx("p", { className: cx(c.message, c.error), children: loadError })) : coupons.length === 0 ? (empty && _jsx("p", { className: c.empty, children: empty })) : (_jsxs(_Fragment, { children: [listHeading && _jsx("div", { className: c.subheading, children: listHeading }), _jsx("ul", { className: c.list, children: coupons.map((coupon) => renderCoupon ? (_jsx("li", { className: c.row, children: renderCoupon(coupon, () => void forget(coupon)) }, coupon.coupon_id)) : (_jsx(CouponRow, { coupon: coupon, c: c, forgetLabel: forgetLabel, onForget: () => void forget(coupon) }, coupon.coupon_id))) })] }))] }));
|
|
@@ -112,7 +112,7 @@ export default function NostrProfilePanel({ npub, onPublished, }) {
|
|
|
112
112
|
setPublishing(false);
|
|
113
113
|
}
|
|
114
114
|
}
|
|
115
|
-
return (_jsxs("div", { className: `${card} px-4 py-3`, children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsxs("div", { className: "relative flex-none", children: [_jsx(Avatar, { value: profile.picture || avatarFor(npub), size: 56 }), _jsx("button", { type: "button", onClick: () => {
|
|
115
|
+
return (_jsxs("div", { className: `${card} px-4 py-3`, "data-tour": "tb.account.profile", children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsxs("div", { className: "relative flex-none", children: [_jsx(Avatar, { value: profile.picture || avatarFor(npub), size: 56 }), _jsx("button", { type: "button", onClick: () => {
|
|
116
116
|
setExpanded(true);
|
|
117
117
|
setShowPicker((v) => !v);
|
|
118
118
|
}, "aria-label": showPicker ? "Done changing avatar" : "Change avatar", "aria-expanded": showPicker, className: "absolute -bottom-0.5 -right-0.5 inline-flex h-6 w-6 items-center justify-center rounded-full border border-[var(--tb-line)] bg-[var(--tb-surface)] shadow-sm", children: _jsx(Camera, { className: "h-3.5 w-3.5", "aria-hidden": "true" }) })] }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("div", { className: "text-sm font-medium truncate", children: collapsedDisplayName(profile.display_name ?? "") }), mode === "how-to-set" && (_jsx("span", { className: `text-[11px] px-1.5 py-0.5 rounded-md bg-[var(--tb-surface-2)] ${muted}`, children: "Read-only" }))] }), _jsx("button", { type: "button", onClick: copyNpub, title: "Copy full npub", className: `mt-0.5 font-mono text-xs ${muted}`, children: copied ? "Copied" : shortNpub(npub) })] }), _jsxs("button", { type: "button", onClick: () => setExpanded((v) => !v), "aria-expanded": expanded, "aria-controls": fieldsId, className: "flex-none inline-flex items-center gap-1 rounded-full border border-[var(--tb-line)] px-2.5 py-1.5 text-xs", children: [expanded ? "Hide" : "Edit", _jsx(ChevronDown, { className: `h-3.5 w-3.5 transition-transform ${expanded ? "rotate-180" : ""}`, "aria-hidden": "true" })] })] }), showPicker && (_jsx("div", { className: "mt-3", children: _jsx(AvatarPicker, { value: profile.picture ?? "", onChange: pickAvatar }) })), loading ? (_jsxs("div", { className: `flex items-center gap-1.5 text-xs py-2 mt-3 ${muted}`, children: [_jsx(Loader2, { className: "h-3.5 w-3.5 animate-spin" }), " Reading from relays\u2026"] })) : (_jsxs("div", { id: fieldsId, hidden: !expanded, className: expanded ? "mt-3 grid gap-x-3 gap-y-2 sm:grid-cols-2" : undefined, children: [FIELDS.map((f) => (_jsxs("label", { className: `block text-xs ${muted} ${f.key === "about" ? "sm:col-span-2" : ""}`, children: [f.label, f.key === "about" ? (_jsx("textarea", { value: profile[f.key] ?? "", onChange: (e) => set(f.key, e.target.value), rows: 2, className: `mt-1 ${input} font-sans resize-none`, placeholder: f.placeholder, readOnly: mode === "how-to-set" })) : (_jsx("input", { value: profile[f.key] ?? "", onChange: (e) => set(f.key, e.target.value), className: `mt-1 ${input} font-sans`, placeholder: f.placeholder, readOnly: mode === "how-to-set" }))] }, f.key))), msg && (_jsx("div", { className: `text-xs sm:col-span-2 ${msg.ok ? "text-[var(--tb-ok)]" : "text-[var(--tb-err-ink)]"}`, children: msg.text })), _jsxs("div", { className: "relative flex items-center gap-3 sm:col-span-2", ref: howToRef, children: [_jsx("button", { type: "button", onClick: () => (mode === "how-to-set" ? setShowHowTo((v) => !v) : void publish()), disabled: publishControlDisabled(mode, publishing), "aria-haspopup": mode === "how-to-set" ? "dialog" : undefined, "aria-expanded": mode === "how-to-set" ? showHowTo : undefined, className: mode === "publish"
|
package/dist/react/NpubGate.js
CHANGED
|
@@ -167,7 +167,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
|
|
|
167
167
|
setPendingProof("");
|
|
168
168
|
setError("");
|
|
169
169
|
}
|
|
170
|
-
return (_jsxs("div", { className: cx("max-w-md mx-auto mt-12 px-4", c.root), children: [_jsxs("h1", { className: "text-xl font-semibold mb-1", children: ["Sign in to ", appName] }), welcome && _jsx("div", { className: cx("text-sm mb-3", c.welcome), children: welcome }), _jsxs("p", { className: cx(`text-sm mb-5 ${muted}`, c.register), children: [appName, " is an operator on the Tollbooth DPYC\u2122 network. DPYC\u2122 \u2014 Don't Pester Your Customer\u2122: no email, no password, no KYC. Your Nostr key is your identity."] }), notice && _jsx("div", { className: `mb-5 ${warnBox}`, children: notice }), stage === "begin" && recents.length > 0 && (_jsxs("div", { className: "mb-5", children: [_jsx("div", { className: `text-xs uppercase tracking-wider mb-2 ${muted}`, children: "Recent identities" }), _jsx("div", { className: "space-y-1.5", children: recents.map((e) => (_jsxs("div", { className: "flex gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => reuseRecent(e), disabled: busy, title: `Re-enter as ${e.npub} on the cached proof`, className: `${card} flex-1 flex items-center justify-between px-3 py-2.5 text-left hover:border-[var(--tb-accent)]`, children: [_jsxs("span", { className: "font-mono text-xs truncate", children: [e.npub.slice(0, 12), "\u2026", e.npub.slice(-6)] }), _jsxs("span", { className: `text-xs shrink-0 ml-2 ${muted}`, children: [ttl(e.expiresAt), " left"] })] }), _jsx("button", { type: "button", onClick: () => forget(e.npub), disabled: busy, title: "Forget this identity", "aria-label": "Forget this identity", className: `w-9 rounded-lg border border-[var(--tb-line)] ${muted}`, children: "\u00D7" })] }, e.npub))) })] })), _jsxs("div", { className: `${card} p-4 space-y-3`, children: [stage === "begin" ? (_jsxs(_Fragment, { children: [_jsx("label", { className: `block text-xs uppercase tracking-wider ${muted}`, htmlFor: "tb-npub-field", children: "Enter npub (secure) or nsec (convenient)" }), _jsx("input", { id: "tb-npub-field", type: isNsec ? "password" : "text", value: value, onChange: (e) => {
|
|
170
|
+
return (_jsxs("div", { className: cx("max-w-md mx-auto mt-12 px-4", c.root), children: [_jsxs("h1", { className: "text-xl font-semibold mb-1", children: ["Sign in to ", appName] }), welcome && _jsx("div", { className: cx("text-sm mb-3", c.welcome), children: welcome }), _jsxs("p", { className: cx(`text-sm mb-5 ${muted}`, c.register), children: [appName, " is an operator on the Tollbooth DPYC\u2122 network. DPYC\u2122 \u2014 Don't Pester Your Customer\u2122: no email, no password, no KYC. Your Nostr key is your identity."] }), notice && _jsx("div", { className: `mb-5 ${warnBox}`, children: notice }), stage === "begin" && recents.length > 0 && (_jsxs("div", { className: "mb-5", children: [_jsx("div", { className: `text-xs uppercase tracking-wider mb-2 ${muted}`, children: "Recent identities" }), _jsx("div", { className: "space-y-1.5", children: recents.map((e) => (_jsxs("div", { className: "flex gap-1.5", children: [_jsxs("button", { type: "button", onClick: () => reuseRecent(e), disabled: busy, title: `Re-enter as ${e.npub} on the cached proof`, className: `${card} flex-1 flex items-center justify-between px-3 py-2.5 text-left hover:border-[var(--tb-accent)]`, children: [_jsxs("span", { className: "font-mono text-xs truncate", children: [e.npub.slice(0, 12), "\u2026", e.npub.slice(-6)] }), _jsxs("span", { className: `text-xs shrink-0 ml-2 ${muted}`, children: [ttl(e.expiresAt), " left"] })] }), _jsx("button", { type: "button", onClick: () => forget(e.npub), disabled: busy, title: "Forget this identity", "aria-label": "Forget this identity", className: `w-9 rounded-lg border border-[var(--tb-line)] ${muted}`, children: "\u00D7" })] }, e.npub))) })] })), _jsxs("div", { className: `${card} p-4 space-y-3`, children: [stage === "begin" ? (_jsxs(_Fragment, { children: [_jsx("label", { className: `block text-xs uppercase tracking-wider ${muted}`, htmlFor: "tb-npub-field", children: "Enter npub (secure) or nsec (convenient)" }), _jsx("input", { id: "tb-npub-field", "data-tour": "tb.gate.npub", type: isNsec ? "password" : "text", value: value, onChange: (e) => {
|
|
171
171
|
setValue(e.target.value);
|
|
172
172
|
setGeneratedHint(false);
|
|
173
173
|
}, onKeyDown: (e) => {
|
|
@@ -177,7 +177,7 @@ export default function NpubGate({ onLogin, startFresh = false, operatorHash, no
|
|
|
177
177
|
signInWithNsec();
|
|
178
178
|
else
|
|
179
179
|
void begin();
|
|
180
|
-
}, placeholder: "npub1\u2026", spellCheck: false, autoCapitalize: "off", autoCorrect: "off", className: input }), generatedHint && (_jsxs("div", { className: warnBox, children: ["New key generated. ", _jsx("b", { children: "Save this nsec" }), " somewhere safe \u2014 it's the only copy and it lives only in this browser."] })), _jsx("button", { type: "button", onClick: () => (isNsec ? signInWithNsec() : void begin()), disabled: !valid || busy, className: primary, children: busy ? "Sending…" : isNpub ? "Send message" : "Sign in" }), isNsec && _jsx("p", { className: `text-xs ${muted}`, children: "Your nsec stays in this browser and signs each call." }), isNpub && (_jsx("p", { className: `text-xs ${muted}`, children: "We send a message to your npub. Approve it from your Nostr client \u2014 your signature is the proof." })), _jsx("p", { className: `text-xs pt-1 ${muted}`, children: "Need a new identity?" }), _jsx("button", { type: "button", onClick: generateKey, className: ghost, children: "Generate a new key" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: `${warnBox} space-y-1.5`, children: [_jsx("div", { className: "font-medium", children: "Message sent \u2014 open your Nostr client." }), _jsx("div", { children: "Reply with any text. Your signature on that reply is the proof." }), operatorHash && (_jsxs("div", { children: ["Sender fingerprint: ", _jsxs("span", { className: "font-mono", children: ["\uD83D\uDD12 ", operatorHash] })] }))] }), pendingProof && (_jsxs("div", { className: "rounded-lg p-3 text-xs border border-[var(--tb-accent)] space-y-2", children: [_jsx("div", { className: `uppercase tracking-wider text-[10px] ${muted}`, children: "Confirmation code" }), _jsx("div", { className: "font-mono text-base select-all", children: pendingProof }), _jsxs("div", { className: `leading-relaxed ${muted}`, children: ["The same code appears in the message. ", _jsx("b", { children: "Approve it only if the codes match." }), " If they differ \u2014 or the message points you somewhere other than this site \u2014 do not reply."] })] })), _jsx("button", { type: "button", onClick: () => void finish(), disabled: busy, className: primary, children: busy ? "Checking…" : "I've replied — verify" }), _jsx("button", { type: "button", onClick: () => void begin(), disabled: busy, className: ghost, children: "Resend" }), _jsx("button", { type: "button", onClick: reset, disabled: busy, className: `w-full text-xs py-1.5 ${muted}`, children: "Use a different npub" })] })), error && _jsx(Trouble, { raw: error, appName: appName }), note && _jsx("div", { className: `text-xs text-center italic ${muted}`, children: note })] }), steps && _jsx(SignInSteps, { stage: gateStage(stage, busy), classNames: { ...STEPS_LOOK, ...c.steps } }), links && _jsx(SignInLinks, { credit: linksCredit, classNames: { ...LINKS_LOOK, ...c.links } })] }));
|
|
180
|
+
}, placeholder: "npub1\u2026", spellCheck: false, autoCapitalize: "off", autoCorrect: "off", className: input }), generatedHint && (_jsxs("div", { className: warnBox, children: ["New key generated. ", _jsx("b", { children: "Save this nsec" }), " somewhere safe \u2014 it's the only copy and it lives only in this browser."] })), _jsx("button", { type: "button", onClick: () => (isNsec ? signInWithNsec() : void begin()), disabled: !valid || busy, className: primary, "data-tour": "tb.gate.send", children: busy ? "Sending…" : isNpub ? "Send message" : "Sign in" }), isNsec && _jsx("p", { className: `text-xs ${muted}`, children: "Your nsec stays in this browser and signs each call." }), isNpub && (_jsx("p", { className: `text-xs ${muted}`, children: "We send a message to your npub. Approve it from your Nostr client \u2014 your signature is the proof." })), _jsx("p", { className: `text-xs pt-1 ${muted}`, children: "Need a new identity?" }), _jsx("button", { type: "button", onClick: generateKey, className: ghost, children: "Generate a new key" })] })) : (_jsxs(_Fragment, { children: [_jsxs("div", { className: `${warnBox} space-y-1.5`, children: [_jsx("div", { className: "font-medium", children: "Message sent \u2014 open your Nostr client." }), _jsx("div", { children: "Reply with any text. Your signature on that reply is the proof." }), operatorHash && (_jsxs("div", { children: ["Sender fingerprint: ", _jsxs("span", { className: "font-mono", children: ["\uD83D\uDD12 ", operatorHash] })] }))] }), pendingProof && (_jsxs("div", { className: "rounded-lg p-3 text-xs border border-[var(--tb-accent)] space-y-2", children: [_jsx("div", { className: `uppercase tracking-wider text-[10px] ${muted}`, children: "Confirmation code" }), _jsx("div", { className: "font-mono text-base select-all", children: pendingProof }), _jsxs("div", { className: `leading-relaxed ${muted}`, children: ["The same code appears in the message. ", _jsx("b", { children: "Approve it only if the codes match." }), " If they differ \u2014 or the message points you somewhere other than this site \u2014 do not reply."] })] })), _jsx("button", { type: "button", onClick: () => void finish(), disabled: busy, className: primary, "data-tour": "tb.gate.verify", children: busy ? "Checking…" : "I've replied — verify" }), _jsx("button", { type: "button", onClick: () => void begin(), disabled: busy, className: ghost, children: "Resend" }), _jsx("button", { type: "button", onClick: reset, disabled: busy, className: `w-full text-xs py-1.5 ${muted}`, children: "Use a different npub" })] })), error && _jsx(Trouble, { raw: error, appName: appName }), note && _jsx("div", { className: `text-xs text-center italic ${muted}`, children: note })] }), steps && _jsx(SignInSteps, { stage: gateStage(stage, busy), classNames: { ...STEPS_LOOK, ...c.steps } }), links && _jsx(SignInLinks, { credit: linksCredit, classNames: { ...LINKS_LOOK, ...c.links } })] }));
|
|
181
181
|
}
|
|
182
182
|
/** A failure, read to the person: the lead first, the service's own words underneath. */
|
|
183
183
|
function Trouble({ raw, appName }) {
|
|
@@ -155,9 +155,9 @@ function SessionKeyClaimInner({ actionClass }) {
|
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
157
|
if (!revealed) {
|
|
158
|
-
return (_jsxs("div", { className: `${card} px-4 py-3`, children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2", children: [_jsx("p", { className: `min-w-[14rem] flex-1 text-[12.5px] leading-snug ${muted}`, children: "This browser is holding the secret key that signs you in. Take a copy before you clear site data or switch devices \u2014 without it, this identity is gone." }), _jsx("button", { type: "button", onClick: () => setRevealed(true), className: "min-h-11 shrink-0 rounded-full border border-[var(--tb-accent)] bg-[var(--tb-accent)] px-4 text-[13px] font-semibold text-[var(--tb-on-accent)]", children: "Claim your session key" })] })] }));
|
|
158
|
+
return (_jsxs("div", { className: `${card} px-4 py-3`, "data-tour": "tb.account.sessionKey", children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsxs("div", { className: "flex flex-wrap items-center gap-x-4 gap-y-2", children: [_jsx("p", { className: `min-w-[14rem] flex-1 text-[12.5px] leading-snug ${muted}`, children: "This browser is holding the secret key that signs you in. Take a copy before you clear site data or switch devices \u2014 without it, this identity is gone." }), _jsx("button", { type: "button", onClick: () => setRevealed(true), className: "min-h-11 shrink-0 rounded-full border border-[var(--tb-accent)] bg-[var(--tb-accent)] px-4 text-[13px] font-semibold text-[var(--tb-on-accent)]", children: "Claim your session key" })] })] }));
|
|
159
159
|
}
|
|
160
|
-
return (_jsxs("div", { className: `relative ${card} px-4 py-3`, children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-2", children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsx("button", { type: "button", onClick: () => {
|
|
160
|
+
return (_jsxs("div", { className: `relative ${card} px-4 py-3`, "data-tour": "tb.account.sessionKey", children: [_jsxs("div", { className: "flex flex-wrap items-baseline justify-between gap-2", children: [_jsx("div", { className: "eyebrow mb-1", children: "Session key" }), _jsx("button", { type: "button", onClick: () => {
|
|
161
161
|
setRevealed(false);
|
|
162
162
|
setShowKey(false);
|
|
163
163
|
setDmOpen(false);
|
|
@@ -2,5 +2,5 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { cx } from "./cx.js";
|
|
3
3
|
import { SIGN_IN_LINKS, SIGN_IN_LINKS_CREDIT } from "./signInTools.js";
|
|
4
4
|
export default function SignInLinks({ links = SIGN_IN_LINKS, lead = "New to Nostr? These two are all it takes.", credit = true, classNames: c = {}, }) {
|
|
5
|
-
return (_jsxs("nav", { "aria-label": "Tools for signing in", className: c.root, children: [lead && _jsx("p", { className: c.lead, children: lead }), _jsx("ul", { className: c.list, children: links.map((l) => (_jsx("li", { className: c.item, children: _jsxs("a", { href: l.href, target: "_blank", rel: "noopener noreferrer", className: cx(c.link), children: [l.logo && _jsx("img", { src: l.logo, alt: "", width: 40, height: 40, className: c.logo }), _jsxs("span", { className: c.text, children: [_jsx("span", { className: c.name, children: l.name }), _jsx("span", { className: c.line, children: l.line })] })] }) }, l.id))) }), credit && _jsx("p", { className: c.credit, children: SIGN_IN_LINKS_CREDIT })] }));
|
|
5
|
+
return (_jsxs("nav", { "aria-label": "Tools for signing in", className: c.root, "data-tour": "tb.gate.links", children: [lead && _jsx("p", { className: c.lead, children: lead }), _jsx("ul", { className: c.list, children: links.map((l) => (_jsx("li", { className: c.item, children: _jsxs("a", { href: l.href, target: "_blank", rel: "noopener noreferrer", className: cx(c.link), children: [l.logo && _jsx("img", { src: l.logo, alt: "", width: 40, height: 40, className: c.logo }), _jsxs("span", { className: c.text, children: [_jsx("span", { className: c.name, children: l.name }), _jsx("span", { className: c.line, children: l.line })] })] }) }, l.id))) }), credit && _jsx("p", { className: c.credit, children: SIGN_IN_LINKS_CREDIT })] }));
|
|
6
6
|
}
|
|
@@ -13,7 +13,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
13
13
|
import { cx } from "./cx.js";
|
|
14
14
|
import { SIGN_IN_ACTORS, SIGN_IN_STEPS, stepState, } from "./signInFlow.js";
|
|
15
15
|
export default function SignInSteps({ stage, steps = SIGN_IN_STEPS, actors = SIGN_IN_ACTORS, label = "How signing in works", classNames: c = {}, }) {
|
|
16
|
-
return (_jsx("ol", { "aria-label": label, className: c.root, children: steps.map((s, i) => {
|
|
16
|
+
return (_jsx("ol", { "aria-label": label, className: c.root, "data-tour": "tb.gate.steps", children: steps.map((s, i) => {
|
|
17
17
|
const state = stepState(s, stage);
|
|
18
18
|
return (_jsxs("li", { "data-state": state, "aria-current": state === "current" ? "step" : undefined, className: cx(c.step, c[state]), children: [_jsx("span", { "aria-hidden": true, className: c.glyph, children: s.glyph }), _jsx("span", { className: c.label, children: s.label }), _jsx("span", { className: c.actor, children: actors[s.actor] }), i < steps.length - 1 && (_jsx("span", { "aria-hidden": true, className: c.connector, children: "\u203A" }))] }, s.id));
|
|
19
19
|
}) }));
|
|
@@ -48,6 +48,6 @@ export default function UsageSummary({ days = 30, heading, figures = ALL, labels
|
|
|
48
48
|
void load();
|
|
49
49
|
}, [load]);
|
|
50
50
|
const shown = facts ? figures.filter((f) => f !== "expired" || facts.expired > 0) : [];
|
|
51
|
-
return (_jsxs("section", { className: c.root, children: [_jsxs("div", { className: c.header, children: [_jsx("div", { className: c.heading, children: heading ?? `Last ${days} days` }), refreshLabel !== null && (_jsx("button", { type: "button", onClick: () => void load(), disabled: loading, className: c.chip, children: loading ? "Loading…" : refreshLabel }))] }), error ? (_jsx("p", { className: c.error, children: error })) : !facts ? (loading && _jsx("p", { className: c.loading, children: "Loading\u2026" })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: c.figures, children: shown.map((f) => (_jsxs("div", { className: c.figure, children: [_jsx("div", { className: c.value, children: facts[f].toLocaleString() }), _jsx("div", { className: c.label, children: labels[f] ?? LABELS[f] })] }, f))) }), topTools > 0 &&
|
|
51
|
+
return (_jsxs("section", { className: c.root, "data-tour": "tb.account.usage", children: [_jsxs("div", { className: c.header, children: [_jsx("div", { className: c.heading, children: heading ?? `Last ${days} days` }), refreshLabel !== null && (_jsx("button", { type: "button", onClick: () => void load(), disabled: loading, className: c.chip, children: loading ? "Loading…" : refreshLabel }))] }), error ? (_jsx("p", { className: c.error, children: error })) : !facts ? (loading && _jsx("p", { className: c.loading, children: "Loading\u2026" })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: c.figures, children: shown.map((f) => (_jsxs("div", { className: c.figure, children: [_jsx("div", { className: c.value, children: facts[f].toLocaleString() }), _jsx("div", { className: c.label, children: labels[f] ?? LABELS[f] })] }, f))) }), topTools > 0 &&
|
|
52
52
|
(facts.tools.length === 0 ? (empty && _jsx("p", { className: c.empty, children: empty })) : (_jsxs(_Fragment, { children: [toolsHeading && _jsx("div", { className: c.subheading, children: toolsHeading }), _jsx("ul", { className: c.list, children: facts.tools.map((t) => (_jsx("li", { className: c.row, children: renderRow ? (renderRow(t)) : (_jsxs(_Fragment, { children: [_jsx("span", { className: c.tool, children: t.tool }), " ", _jsxs("span", { className: c.calls, children: [t.calls.toLocaleString(), " call", t.calls === 1 ? "" : "s"] }), " ", _jsxs("span", { className: c.sats, children: [t.sats.toLocaleString(), " sats"] })] })) }, t.tool))) })] })))] }))] }));
|
|
53
53
|
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -33,3 +33,5 @@ export { useDebugLog } from "./useDebugLog.ts";
|
|
|
33
33
|
export { useTheme } from "./useTheme.ts";
|
|
34
34
|
export { useTimezone } from "./useTimezone.ts";
|
|
35
35
|
export { useTopUp, type TopUp, type UseTopUpOptions } from "./useTopUp.ts";
|
|
36
|
+
export { TourProvider, tourTarget, useTour, useTourReplay, useTourSwitch, type TourProviderProps, type UseTourOptions, } from "./useTour.tsx";
|
|
37
|
+
export { buildTour, offerTour, OFFER_WORDS, type Offer, type OfferHooks, type OfferWords, type TourHooks, type TourRun } from "../tour/run.ts";
|
package/dist/react/index.js
CHANGED
|
@@ -32,3 +32,5 @@ export { useDebugLog } from "./useDebugLog.js";
|
|
|
32
32
|
export { useTheme } from "./useTheme.js";
|
|
33
33
|
export { useTimezone } from "./useTimezone.js";
|
|
34
34
|
export { useTopUp } from "./useTopUp.js";
|
|
35
|
+
export { TourProvider, tourTarget, useTour, useTourReplay, useTourSwitch, } from "./useTour.js";
|
|
36
|
+
export { buildTour, offerTour, OFFER_WORDS } from "../tour/run.js";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guided tour: who sees it, when it is offered, and how a page asks for
|
|
3
|
+
* its own.
|
|
4
|
+
*
|
|
5
|
+
* `TourProvider` holds the site's tours and the preference. A page calls
|
|
6
|
+
* `useTour(page, {actions})` once; when this device is due that page's tour,
|
|
7
|
+
* the page is watched for a quiet stretch and then asked, centred, whether it
|
|
8
|
+
* would like one. Yes starts the tour; No marks the page. A page asked to
|
|
9
|
+
* replay from its guide starts at once. `actions` are what a step may ask
|
|
10
|
+
* the page to do first — open the add form, pick a tag — by the names the
|
|
11
|
+
* copy uses.
|
|
12
|
+
*
|
|
13
|
+
* The site keeps its own switch and its own replay button: `useTourSwitch`
|
|
14
|
+
* and `useTourReplay` give them their mechanics.
|
|
15
|
+
*/
|
|
16
|
+
import { type ReactNode } from "react";
|
|
17
|
+
import { type OfferWords } from "../tour/run.ts";
|
|
18
|
+
import type { Tour, TourActions } from "../tour/types.ts";
|
|
19
|
+
export interface TourProviderProps {
|
|
20
|
+
/** The site's tours, by page key — its own and the package's two. */
|
|
21
|
+
tours: Record<string, Tour>;
|
|
22
|
+
/** The quiet stretch before a page offers its tour. Defaults to the site's `tourIdleMs`, else 10 s. */
|
|
23
|
+
idleMs?: number;
|
|
24
|
+
/** The offer's words, when a site wants its own. */
|
|
25
|
+
words?: OfferWords;
|
|
26
|
+
children: ReactNode;
|
|
27
|
+
}
|
|
28
|
+
export declare function TourProvider({ tours, idleMs, words, children }: TourProviderProps): import("react").JSX.Element;
|
|
29
|
+
export interface UseTourOptions {
|
|
30
|
+
actions?: TourActions;
|
|
31
|
+
/** False while the page has yet to draw what the tour points at. */
|
|
32
|
+
ready?: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** Ask for this page's tour, if this device is due it. */
|
|
35
|
+
export declare function useTour(page: string, { actions, ready }?: UseTourOptions): void;
|
|
36
|
+
/** The site's "Live tutorial" switch: on replays every page, off stops them all. */
|
|
37
|
+
export declare function useTourSwitch(): {
|
|
38
|
+
on: boolean;
|
|
39
|
+
setOn: (on: boolean) => void;
|
|
40
|
+
};
|
|
41
|
+
/** The site's "Show me on the page" button: that page's tour once more, at once. */
|
|
42
|
+
export declare function useTourReplay(): (page: string) => void;
|
|
43
|
+
/** The attribute to spread on the element a step points at. */
|
|
44
|
+
export declare function tourTarget(name: string): {
|
|
45
|
+
"data-tour": string;
|
|
46
|
+
};
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The guided tour: who sees it, when it is offered, and how a page asks for
|
|
4
|
+
* its own.
|
|
5
|
+
*
|
|
6
|
+
* `TourProvider` holds the site's tours and the preference. A page calls
|
|
7
|
+
* `useTour(page, {actions})` once; when this device is due that page's tour,
|
|
8
|
+
* the page is watched for a quiet stretch and then asked, centred, whether it
|
|
9
|
+
* would like one. Yes starts the tour; No marks the page. A page asked to
|
|
10
|
+
* replay from its guide starts at once. `actions` are what a step may ask
|
|
11
|
+
* the page to do first — open the add form, pick a tag — by the names the
|
|
12
|
+
* copy uses.
|
|
13
|
+
*
|
|
14
|
+
* The site keeps its own switch and its own replay button: `useTourSwitch`
|
|
15
|
+
* and `useTourReplay` give them their mechanics.
|
|
16
|
+
*/
|
|
17
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
18
|
+
import { tollboothConfig } from "../config.js";
|
|
19
|
+
import { ACTIVITY_EVENTS, idleThreshold, offerDue } from "../tour/idle.js";
|
|
20
|
+
import { buildTour, offerTour } from "../tour/run.js";
|
|
21
|
+
import { markToured, replayAll, replayPage, shouldRun, stopAll } from "../tour/state.js";
|
|
22
|
+
import { onTourChange, readTourPrefs, settleTourPrefs, tourStorageKey, writeTourPrefs } from "../tourPrefs.js";
|
|
23
|
+
const Ctx = createContext(null);
|
|
24
|
+
function subscribe(changed) {
|
|
25
|
+
const stopPage = onTourChange(changed);
|
|
26
|
+
const onStorage = (e) => {
|
|
27
|
+
try {
|
|
28
|
+
if (e.key === tourStorageKey())
|
|
29
|
+
changed();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
/* not configured yet: nothing of ours changed */
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
globalThis.addEventListener?.("storage", onStorage);
|
|
36
|
+
return () => {
|
|
37
|
+
stopPage();
|
|
38
|
+
globalThis.removeEventListener?.("storage", onStorage);
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
const onServer = () => ({ on: false, toured: {}, replay: null });
|
|
42
|
+
export function TourProvider({ tours, idleMs, words, children }) {
|
|
43
|
+
const prefs = useSyncExternalStore(subscribe, readTourPrefs, onServer);
|
|
44
|
+
const update = useCallback((change) => {
|
|
45
|
+
writeTourPrefs(change(readTourPrefs()));
|
|
46
|
+
}, []);
|
|
47
|
+
const threshold = idleThreshold(tollboothConfig().tourIdleMs, idleMs);
|
|
48
|
+
// A newcomer's first reading is written down, so signing in a minute later
|
|
49
|
+
// does not turn them into an old hand.
|
|
50
|
+
useEffect(() => { settleTourPrefs(); }, []);
|
|
51
|
+
const value = useMemo(() => ({ prefs, update, tours, idleMs: threshold, words }), [prefs, update, tours, threshold, words]);
|
|
52
|
+
return _jsx(Ctx.Provider, { value: value, children: children });
|
|
53
|
+
}
|
|
54
|
+
/** Ask for this page's tour, if this device is due it. */
|
|
55
|
+
export function useTour(page, { actions, ready = true } = {}) {
|
|
56
|
+
const ctx = useContext(Ctx);
|
|
57
|
+
const acts = useRef(actions);
|
|
58
|
+
acts.current = actions;
|
|
59
|
+
const due = ctx ? shouldRun(ctx.prefs, page) : false;
|
|
60
|
+
const atOnce = ctx?.prefs.replay === page;
|
|
61
|
+
const update = ctx?.update;
|
|
62
|
+
const tour = ctx?.tours[page];
|
|
63
|
+
const idleMs = ctx?.idleMs ?? 0;
|
|
64
|
+
const words = ctx?.words;
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!due || !ready || !update || !tour)
|
|
67
|
+
return;
|
|
68
|
+
let gone = false;
|
|
69
|
+
let run = null;
|
|
70
|
+
let offer = null;
|
|
71
|
+
const start = () => {
|
|
72
|
+
if (gone)
|
|
73
|
+
return;
|
|
74
|
+
run = buildTour(tour, {
|
|
75
|
+
run: (name) => acts.current?.[name]?.(),
|
|
76
|
+
onEnd: () => update((p) => markToured(p, page)),
|
|
77
|
+
onStop: () => update(stopAll),
|
|
78
|
+
});
|
|
79
|
+
run.start();
|
|
80
|
+
};
|
|
81
|
+
// From a guide, at once; otherwise after a quiet while, and only asked.
|
|
82
|
+
if (atOnce) {
|
|
83
|
+
const timer = window.setTimeout(start, 600);
|
|
84
|
+
return () => { gone = true; window.clearTimeout(timer); run?.stop(true); };
|
|
85
|
+
}
|
|
86
|
+
let last = Date.now();
|
|
87
|
+
const touched = () => { last = Date.now(); };
|
|
88
|
+
for (const ev of ACTIVITY_EVENTS)
|
|
89
|
+
window.addEventListener(ev, touched, { passive: true, capture: true });
|
|
90
|
+
const tick = window.setInterval(() => {
|
|
91
|
+
if (!offerDue(last, Date.now(), document.visibilityState === "visible", idleMs))
|
|
92
|
+
return;
|
|
93
|
+
window.clearInterval(tick);
|
|
94
|
+
for (const ev of ACTIVITY_EVENTS)
|
|
95
|
+
window.removeEventListener(ev, touched, { capture: true });
|
|
96
|
+
offer = offerTour({
|
|
97
|
+
onYes: start,
|
|
98
|
+
onNo: () => update((p) => markToured(p, page)),
|
|
99
|
+
}, words);
|
|
100
|
+
offer.show();
|
|
101
|
+
}, 1000);
|
|
102
|
+
return () => {
|
|
103
|
+
gone = true;
|
|
104
|
+
window.clearInterval(tick);
|
|
105
|
+
for (const ev of ACTIVITY_EVENTS)
|
|
106
|
+
window.removeEventListener(ev, touched, { capture: true });
|
|
107
|
+
offer?.dismiss();
|
|
108
|
+
run?.stop(true);
|
|
109
|
+
};
|
|
110
|
+
}, [page, due, atOnce, ready, update, tour, idleMs, words]);
|
|
111
|
+
}
|
|
112
|
+
/** The site's "Live tutorial" switch: on replays every page, off stops them all. */
|
|
113
|
+
export function useTourSwitch() {
|
|
114
|
+
const ctx = useContext(Ctx);
|
|
115
|
+
const update = ctx?.update;
|
|
116
|
+
const setOn = useCallback((on) => { update?.(on ? replayAll : stopAll); }, [update]);
|
|
117
|
+
return { on: ctx?.prefs.on ?? false, setOn };
|
|
118
|
+
}
|
|
119
|
+
/** The site's "Show me on the page" button: that page's tour once more, at once. */
|
|
120
|
+
export function useTourReplay() {
|
|
121
|
+
const ctx = useContext(Ctx);
|
|
122
|
+
const update = ctx?.update;
|
|
123
|
+
return useCallback((page) => { update?.((p) => replayPage(p, page)); }, [update]);
|
|
124
|
+
}
|
|
125
|
+
/** The attribute to spread on the element a step points at. */
|
|
126
|
+
export function tourTarget(name) {
|
|
127
|
+
return { "data-tour": name };
|
|
128
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When to offer a tour: after a stretch with nothing touched.
|
|
3
|
+
*
|
|
4
|
+
* A tour that starts the moment a page opens gets in the way of someone who
|
|
5
|
+
* knows the page. One offered after a pause finds the person who is looking
|
|
6
|
+
* for where to start. The policy is data here, so it is tested; the hook
|
|
7
|
+
* that listens for the events is in `react/useTour.ts`.
|
|
8
|
+
*/
|
|
9
|
+
/** How long a page may sit untouched before a tour is offered. */
|
|
10
|
+
export declare const DEFAULT_IDLE_MS = 10000;
|
|
11
|
+
/** Anything a person does to a page. */
|
|
12
|
+
export declare const ACTIVITY_EVENTS: readonly ["pointerdown", "pointermove", "keydown", "wheel", "touchstart", "scroll"];
|
|
13
|
+
/** The idle stretch a site asked for, or the default; never under a second. */
|
|
14
|
+
export declare function idleThreshold(configured: number | undefined, override?: number): number;
|
|
15
|
+
/**
|
|
16
|
+
* Whether a page has sat untouched long enough. A hidden tab is never idle:
|
|
17
|
+
* someone reading another tab has not gone quiet on this one.
|
|
18
|
+
*/
|
|
19
|
+
export declare function offerDue(lastActivityAt: number, now: number, visible: boolean, idleMs: number): boolean;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When to offer a tour: after a stretch with nothing touched.
|
|
3
|
+
*
|
|
4
|
+
* A tour that starts the moment a page opens gets in the way of someone who
|
|
5
|
+
* knows the page. One offered after a pause finds the person who is looking
|
|
6
|
+
* for where to start. The policy is data here, so it is tested; the hook
|
|
7
|
+
* that listens for the events is in `react/useTour.ts`.
|
|
8
|
+
*/
|
|
9
|
+
/** How long a page may sit untouched before a tour is offered. */
|
|
10
|
+
export const DEFAULT_IDLE_MS = 10_000;
|
|
11
|
+
/** Anything a person does to a page. */
|
|
12
|
+
export const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "wheel", "touchstart", "scroll"];
|
|
13
|
+
/** The idle stretch a site asked for, or the default; never under a second. */
|
|
14
|
+
export function idleThreshold(configured, override) {
|
|
15
|
+
const ms = override ?? configured ?? DEFAULT_IDLE_MS;
|
|
16
|
+
return Number.isFinite(ms) && ms >= 1_000 ? ms : DEFAULT_IDLE_MS;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether a page has sat untouched long enough. A hidden tab is never idle:
|
|
20
|
+
* someone reading another tab has not gone quiet on this one.
|
|
21
|
+
*/
|
|
22
|
+
export function offerDue(lastActivityAt, now, visible, idleMs) {
|
|
23
|
+
return visible && now - lastActivityAt >= idleMs;
|
|
24
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The voice a tour speaks in, checked.
|
|
3
|
+
*
|
|
4
|
+
* A step says what a control is and what to do with it, in one or two short
|
|
5
|
+
* sentences. It never says what something is not, and never contrasts: "your
|
|
6
|
+
* npub is your key" and never "your key, not a password". A site's test runs
|
|
7
|
+
* its tours through this so every site holds the same voice.
|
|
8
|
+
*/
|
|
9
|
+
import type { Tour, TourStep } from "./types.ts";
|
|
10
|
+
export declare const MAX_SAYS = 180;
|
|
11
|
+
export declare const MAX_TITLE = 28;
|
|
12
|
+
export declare const MAX_SENTENCES = 2;
|
|
13
|
+
/** The problems with one step's words, each a sentence; none when it is well said. */
|
|
14
|
+
export declare function lintStep(s: TourStep, page?: string): string[];
|
|
15
|
+
/** Every problem in a tour's words. */
|
|
16
|
+
export declare function lintTour(t: Tour): string[];
|
|
17
|
+
/** Every problem across a site's tours. */
|
|
18
|
+
export declare function lintTours(tours: Record<string, Tour>): string[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The voice a tour speaks in, checked.
|
|
3
|
+
*
|
|
4
|
+
* A step says what a control is and what to do with it, in one or two short
|
|
5
|
+
* sentences. It never says what something is not, and never contrasts: "your
|
|
6
|
+
* npub is your key" and never "your key, not a password". A site's test runs
|
|
7
|
+
* its tours through this so every site holds the same voice.
|
|
8
|
+
*/
|
|
9
|
+
const CONTRAST = /\b(not|no|never|nothing|instead of|rather than)\b/i;
|
|
10
|
+
export const MAX_SAYS = 180;
|
|
11
|
+
export const MAX_TITLE = 28;
|
|
12
|
+
export const MAX_SENTENCES = 2;
|
|
13
|
+
function sentences(text) {
|
|
14
|
+
return text.split(/[.!?](\s|$)/).filter((x) => x.trim()).length;
|
|
15
|
+
}
|
|
16
|
+
/** The problems with one step's words, each a sentence; none when it is well said. */
|
|
17
|
+
export function lintStep(s, page = "") {
|
|
18
|
+
const where = page ? `${page} "${s.title}"` : `"${s.title}"`;
|
|
19
|
+
const out = [];
|
|
20
|
+
if (!s.title.trim())
|
|
21
|
+
out.push(`${where}: has no title`);
|
|
22
|
+
if (s.title.length > MAX_TITLE)
|
|
23
|
+
out.push(`${where}: the title runs ${s.title.length} characters`);
|
|
24
|
+
if (!s.says.trim())
|
|
25
|
+
out.push(`${where}: says nothing`);
|
|
26
|
+
if (s.says.length > MAX_SAYS)
|
|
27
|
+
out.push(`${where}: runs ${s.says.length} characters`);
|
|
28
|
+
if (sentences(s.says) > MAX_SENTENCES)
|
|
29
|
+
out.push(`${where}: is more than ${MAX_SENTENCES} sentences`);
|
|
30
|
+
const m = CONTRAST.exec(s.says) ?? CONTRAST.exec(s.title);
|
|
31
|
+
if (m)
|
|
32
|
+
out.push(`${where}: says what something is not ("${m[0]}"): ${s.says}`);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
/** Every problem in a tour's words. */
|
|
36
|
+
export function lintTour(t) {
|
|
37
|
+
const out = [];
|
|
38
|
+
if (!t.steps.length)
|
|
39
|
+
out.push(`${t.page}: has no steps`);
|
|
40
|
+
for (const s of t.steps)
|
|
41
|
+
out.push(...lintStep(s, t.page));
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
/** Every problem across a site's tours. */
|
|
45
|
+
export function lintTours(tours) {
|
|
46
|
+
return Object.values(tours).flatMap(lintTour);
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tour as driver.js runs it, and the offer that precedes it.
|
|
3
|
+
*
|
|
4
|
+
* driver.js draws the popover and the cutout. This adds what it has no idea
|
|
5
|
+
* of: a page action before a step whose control must be opened first, a tour
|
|
6
|
+
* that ends when nothing later can be shown, a "Stop the tutorial" link in
|
|
7
|
+
* every popover, and the centred question a page asks after a quiet while.
|
|
8
|
+
*
|
|
9
|
+
* DOM code, so it is untested here as its peers are; the rules it applies
|
|
10
|
+
* live in `./state.ts` and `./idle.ts`, which are.
|
|
11
|
+
*/
|
|
12
|
+
import type { Tour } from "./types.ts";
|
|
13
|
+
export interface TourRun {
|
|
14
|
+
start(): void;
|
|
15
|
+
/** Take the tour down. `silent` is for the page leaving under it. */
|
|
16
|
+
stop(silent?: boolean): void;
|
|
17
|
+
}
|
|
18
|
+
export interface TourHooks {
|
|
19
|
+
/** Do what a step asks for before it is pointed at. */
|
|
20
|
+
run(action: string): void | Promise<void>;
|
|
21
|
+
/** The tour ended by its last step or its ✕: this page is done. */
|
|
22
|
+
onEnd(): void;
|
|
23
|
+
/** "Stop the tutorial": off on every page. */
|
|
24
|
+
onStop(): void;
|
|
25
|
+
}
|
|
26
|
+
export declare const POPOVER_CLASS = "tb-tour";
|
|
27
|
+
export declare function selector(name: string): string;
|
|
28
|
+
export declare function buildTour(tour: Tour, hooks: TourHooks): TourRun;
|
|
29
|
+
export interface OfferHooks {
|
|
30
|
+
onYes(): void;
|
|
31
|
+
/** "No", the ✕, or a tap outside: this page is done being asked. */
|
|
32
|
+
onNo(): void;
|
|
33
|
+
}
|
|
34
|
+
export interface Offer {
|
|
35
|
+
show(): void;
|
|
36
|
+
/** Take the question down without answering it. */
|
|
37
|
+
dismiss(): void;
|
|
38
|
+
}
|
|
39
|
+
/** The question a page asks after a quiet while, centred, with Yes and No. */
|
|
40
|
+
export declare function offerTour(hooks: OfferHooks, words?: OfferWords): Offer;
|
|
41
|
+
export interface OfferWords {
|
|
42
|
+
title: string;
|
|
43
|
+
says: string;
|
|
44
|
+
yes: string;
|
|
45
|
+
no: string;
|
|
46
|
+
}
|
|
47
|
+
export declare const OFFER_WORDS: OfferWords;
|
package/dist/tour/run.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tour as driver.js runs it, and the offer that precedes it.
|
|
3
|
+
*
|
|
4
|
+
* driver.js draws the popover and the cutout. This adds what it has no idea
|
|
5
|
+
* of: a page action before a step whose control must be opened first, a tour
|
|
6
|
+
* that ends when nothing later can be shown, a "Stop the tutorial" link in
|
|
7
|
+
* every popover, and the centred question a page asks after a quiet while.
|
|
8
|
+
*
|
|
9
|
+
* DOM code, so it is untested here as its peers are; the rules it applies
|
|
10
|
+
* live in `./state.ts` and `./idle.ts`, which are.
|
|
11
|
+
*/
|
|
12
|
+
import { driver } from "driver.js";
|
|
13
|
+
/** How long to wait for a control that a page action is still opening. */
|
|
14
|
+
const WAIT_MS = 1500;
|
|
15
|
+
export const POPOVER_CLASS = "tb-tour";
|
|
16
|
+
export function selector(name) {
|
|
17
|
+
return `[data-tour="${name}"]`;
|
|
18
|
+
}
|
|
19
|
+
/** Whether a step's control is on the page right now. */
|
|
20
|
+
function present(s) {
|
|
21
|
+
return typeof s.target === "string" ? !!document.querySelector(selector(s.target)) : !!s.target();
|
|
22
|
+
}
|
|
23
|
+
export function buildTour(tour, hooks) {
|
|
24
|
+
let silent = false;
|
|
25
|
+
// The step at or after `from` that can be shown: its control is on the
|
|
26
|
+
// page, or its page action will put it there. driver.js's own skipping of
|
|
27
|
+
// a missing element loses its place when the FIRST steps are missing, so
|
|
28
|
+
// the tour moves by index and decides for itself.
|
|
29
|
+
const showable = (from) => tour.steps.findIndex((s, j) => j >= from && (Boolean(s.before) || present(s)));
|
|
30
|
+
const showableBack = (from) => {
|
|
31
|
+
for (let j = from; j >= 0; j--)
|
|
32
|
+
if (present(tour.steps[j]))
|
|
33
|
+
return j;
|
|
34
|
+
return -1;
|
|
35
|
+
};
|
|
36
|
+
const steps = tour.steps.map((s) => ({
|
|
37
|
+
// driver.js types a finder as returning an Element; one that finds
|
|
38
|
+
// nothing returns null, which it treats as a missing element and skips.
|
|
39
|
+
element: typeof s.target === "string" ? selector(s.target) : s.target,
|
|
40
|
+
popover: {
|
|
41
|
+
title: s.title,
|
|
42
|
+
description: s.says,
|
|
43
|
+
side: s.side ?? "bottom",
|
|
44
|
+
align: s.align ?? "start",
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
const d = driver({
|
|
48
|
+
steps,
|
|
49
|
+
popoverClass: POPOVER_CLASS,
|
|
50
|
+
showProgress: true,
|
|
51
|
+
progressText: "{{current}} of {{total}}",
|
|
52
|
+
nextBtnText: "Next",
|
|
53
|
+
prevBtnText: "Back",
|
|
54
|
+
doneBtnText: "Done",
|
|
55
|
+
allowClose: true,
|
|
56
|
+
overlayOpacity: 0.45,
|
|
57
|
+
stagePadding: 6,
|
|
58
|
+
stageRadius: 8,
|
|
59
|
+
smoothScroll: true,
|
|
60
|
+
skipMissingElement: true,
|
|
61
|
+
waitForElement: WAIT_MS,
|
|
62
|
+
onPopoverRender(popover) {
|
|
63
|
+
const stop = document.createElement("button");
|
|
64
|
+
stop.type = "button";
|
|
65
|
+
stop.className = "tb-tour-stop";
|
|
66
|
+
stop.textContent = "Stop the tutorial";
|
|
67
|
+
stop.addEventListener("click", () => { silent = true; hooks.onStop(); d.destroy(); });
|
|
68
|
+
popover.wrapper.appendChild(stop);
|
|
69
|
+
},
|
|
70
|
+
async onNextClick() {
|
|
71
|
+
// With no showable step left the tour is over.
|
|
72
|
+
const i = d.getActiveIndex() ?? 0;
|
|
73
|
+
const n = showable(i + 1);
|
|
74
|
+
if (n < 0) {
|
|
75
|
+
d.destroy();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const next = tour.steps[n];
|
|
79
|
+
if (next.before)
|
|
80
|
+
await hooks.run(next.before);
|
|
81
|
+
d.moveTo(n);
|
|
82
|
+
},
|
|
83
|
+
onPrevClick() {
|
|
84
|
+
const i = d.getActiveIndex() ?? 0;
|
|
85
|
+
const b = showableBack(i - 1);
|
|
86
|
+
if (b >= 0)
|
|
87
|
+
d.moveTo(b);
|
|
88
|
+
},
|
|
89
|
+
onDestroyed() {
|
|
90
|
+
if (!silent)
|
|
91
|
+
hooks.onEnd();
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
start() {
|
|
96
|
+
const i = showable(0);
|
|
97
|
+
if (i < 0) {
|
|
98
|
+
hooks.onEnd();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const first = tour.steps[i];
|
|
102
|
+
if (first.before)
|
|
103
|
+
void Promise.resolve(hooks.run(first.before)).then(() => d.drive(i));
|
|
104
|
+
else
|
|
105
|
+
d.drive(i);
|
|
106
|
+
},
|
|
107
|
+
stop(quiet = true) {
|
|
108
|
+
silent = quiet;
|
|
109
|
+
if (d.isActive())
|
|
110
|
+
d.destroy();
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/** The question a page asks after a quiet while, centred, with Yes and No. */
|
|
115
|
+
export function offerTour(hooks, words = OFFER_WORDS) {
|
|
116
|
+
let answered = false;
|
|
117
|
+
let quiet = false;
|
|
118
|
+
const d = driver({
|
|
119
|
+
popoverClass: `${POPOVER_CLASS} tb-tour-offer`,
|
|
120
|
+
showButtons: [],
|
|
121
|
+
allowClose: true,
|
|
122
|
+
overlayOpacity: 0.3,
|
|
123
|
+
onPopoverRender(popover) {
|
|
124
|
+
const row = document.createElement("div");
|
|
125
|
+
row.className = "tb-tour-answer";
|
|
126
|
+
const no = document.createElement("button");
|
|
127
|
+
no.type = "button";
|
|
128
|
+
no.className = "tb-tour-no";
|
|
129
|
+
no.textContent = words.no;
|
|
130
|
+
no.addEventListener("click", () => { answered = true; d.destroy(); hooks.onNo(); });
|
|
131
|
+
const yes = document.createElement("button");
|
|
132
|
+
yes.type = "button";
|
|
133
|
+
yes.className = "tb-tour-yes";
|
|
134
|
+
yes.textContent = words.yes;
|
|
135
|
+
// The tour starts on the next tick. Started inside this click, the
|
|
136
|
+
// click would go on to the new tour's outside-click listener and close
|
|
137
|
+
// it before it was seen.
|
|
138
|
+
yes.addEventListener("click", () => { answered = true; d.destroy(); window.setTimeout(hooks.onYes, 0); });
|
|
139
|
+
row.append(no, yes);
|
|
140
|
+
popover.wrapper.appendChild(row);
|
|
141
|
+
},
|
|
142
|
+
onDestroyed() {
|
|
143
|
+
if (!answered && !quiet)
|
|
144
|
+
hooks.onNo();
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
return {
|
|
148
|
+
show() {
|
|
149
|
+
d.highlight({ popover: { title: words.title, description: words.says } });
|
|
150
|
+
},
|
|
151
|
+
dismiss() {
|
|
152
|
+
quiet = true;
|
|
153
|
+
if (d.isActive())
|
|
154
|
+
d.destroy();
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
export const OFFER_WORDS = {
|
|
159
|
+
title: "A tour?",
|
|
160
|
+
says: "Would you like a tour of this page? It points at each control and says what it is for.",
|
|
161
|
+
yes: "Yes",
|
|
162
|
+
no: "No",
|
|
163
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who sees a tour, and when it stops.
|
|
3
|
+
*
|
|
4
|
+
* Three facts, kept per site and per device: whether the live tutorial is on,
|
|
5
|
+
* which pages have had their tour, and a page asked to replay once from its
|
|
6
|
+
* guide. A new device starts with the tutorial on. A device that already
|
|
7
|
+
* holds a patron npub but no tour preference has been here since before the
|
|
8
|
+
* tour existed, and starts with it off — a patron who has used the site for a
|
|
9
|
+
* season is shown nothing until they ask.
|
|
10
|
+
*/
|
|
11
|
+
export interface TourPrefs {
|
|
12
|
+
on: boolean;
|
|
13
|
+
toured: Record<string, true>;
|
|
14
|
+
/** A page asked to show its tour once more, whatever the switch says. */
|
|
15
|
+
replay: string | null;
|
|
16
|
+
}
|
|
17
|
+
export declare const TOUR_DEFAULTS: TourPrefs;
|
|
18
|
+
/** Should this page's tour be offered now? */
|
|
19
|
+
export declare function shouldRun(p: TourPrefs, page: string): boolean;
|
|
20
|
+
/** The page's tour ended — by its last step, its ✕, or a "No". Done for this device. */
|
|
21
|
+
export declare function markToured<P extends TourPrefs>(p: P, page: string): P;
|
|
22
|
+
/** "Stop the tutorial", from any popover: off on every page. */
|
|
23
|
+
export declare function stopAll<P extends TourPrefs>(p: P): P;
|
|
24
|
+
/** The switch turned on in the profile: every page offers its tour again. */
|
|
25
|
+
export declare function replayAll<P extends TourPrefs>(p: P): P;
|
|
26
|
+
/** "Show me on the page", from a guide: that page once more, and only it. */
|
|
27
|
+
export declare function replayPage<P extends TourPrefs>(p: P, page: string): P;
|
|
28
|
+
/**
|
|
29
|
+
* The preference as stored, read back. `raw` is the parsed JSON or null when
|
|
30
|
+
* nothing is stored; `oldHand` says whether this device already held a patron
|
|
31
|
+
* npub, which is what makes a device with no preference an old hand rather
|
|
32
|
+
* than a newcomer.
|
|
33
|
+
*/
|
|
34
|
+
export declare function fromStored(raw: unknown, oldHand: boolean): TourPrefs;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who sees a tour, and when it stops.
|
|
3
|
+
*
|
|
4
|
+
* Three facts, kept per site and per device: whether the live tutorial is on,
|
|
5
|
+
* which pages have had their tour, and a page asked to replay once from its
|
|
6
|
+
* guide. A new device starts with the tutorial on. A device that already
|
|
7
|
+
* holds a patron npub but no tour preference has been here since before the
|
|
8
|
+
* tour existed, and starts with it off — a patron who has used the site for a
|
|
9
|
+
* season is shown nothing until they ask.
|
|
10
|
+
*/
|
|
11
|
+
export const TOUR_DEFAULTS = { on: true, toured: {}, replay: null };
|
|
12
|
+
/** Should this page's tour be offered now? */
|
|
13
|
+
export function shouldRun(p, page) {
|
|
14
|
+
if (p.replay === page)
|
|
15
|
+
return true;
|
|
16
|
+
return p.on && !p.toured[page];
|
|
17
|
+
}
|
|
18
|
+
/** The page's tour ended — by its last step, its ✕, or a "No". Done for this device. */
|
|
19
|
+
export function markToured(p, page) {
|
|
20
|
+
return { ...p, toured: { ...p.toured, [page]: true }, replay: p.replay === page ? null : p.replay };
|
|
21
|
+
}
|
|
22
|
+
/** "Stop the tutorial", from any popover: off on every page. */
|
|
23
|
+
export function stopAll(p) {
|
|
24
|
+
return { ...p, on: false, replay: null };
|
|
25
|
+
}
|
|
26
|
+
/** The switch turned on in the profile: every page offers its tour again. */
|
|
27
|
+
export function replayAll(p) {
|
|
28
|
+
return { ...p, on: true, toured: {}, replay: null };
|
|
29
|
+
}
|
|
30
|
+
/** "Show me on the page", from a guide: that page once more, and only it. */
|
|
31
|
+
export function replayPage(p, page) {
|
|
32
|
+
const toured = { ...p.toured };
|
|
33
|
+
delete toured[page];
|
|
34
|
+
return { ...p, toured, replay: page };
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The preference as stored, read back. `raw` is the parsed JSON or null when
|
|
38
|
+
* nothing is stored; `oldHand` says whether this device already held a patron
|
|
39
|
+
* npub, which is what makes a device with no preference an old hand rather
|
|
40
|
+
* than a newcomer.
|
|
41
|
+
*/
|
|
42
|
+
export function fromStored(raw, oldHand) {
|
|
43
|
+
if (raw == null || typeof raw !== "object")
|
|
44
|
+
return { ...TOUR_DEFAULTS, on: !oldHand };
|
|
45
|
+
const r = raw;
|
|
46
|
+
const toured = r.toured && typeof r.toured === "object"
|
|
47
|
+
? Object.fromEntries(Object.entries(r.toured).filter(([, v]) => v === true))
|
|
48
|
+
: {};
|
|
49
|
+
return {
|
|
50
|
+
on: typeof r.on === "boolean" ? r.on : !oldHand,
|
|
51
|
+
toured,
|
|
52
|
+
replay: typeof r.replay === "string" ? r.replay : null,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two tours every site shares, because the pages are the package's: the
|
|
3
|
+
* sign-in gate, and the account page's panels. A site puts them in its tour
|
|
4
|
+
* map beside its own, and adds its own steps after the panels' for whatever
|
|
5
|
+
* else its account page holds.
|
|
6
|
+
*
|
|
7
|
+
* The copy says what each thing is, in the words the network uses — Patron,
|
|
8
|
+
* npub, MCP tool, api_sats — in a sentence or two.
|
|
9
|
+
*/
|
|
10
|
+
import type { Tour, TourStep } from "./types.ts";
|
|
11
|
+
/** The `data-tour` names the package's own markup carries. */
|
|
12
|
+
export declare const TB_TARGETS: {
|
|
13
|
+
readonly gateNpub: "tb.gate.npub";
|
|
14
|
+
readonly gateLinks: "tb.gate.links";
|
|
15
|
+
readonly gateSend: "tb.gate.send";
|
|
16
|
+
readonly gateSteps: "tb.gate.steps";
|
|
17
|
+
readonly gateVerify: "tb.gate.verify";
|
|
18
|
+
readonly accountProfile: "tb.account.profile";
|
|
19
|
+
readonly accountUsage: "tb.account.usage";
|
|
20
|
+
readonly accountCoupons: "tb.account.coupons";
|
|
21
|
+
readonly accountSessionKey: "tb.account.sessionKey";
|
|
22
|
+
};
|
|
23
|
+
/** The sign-in gate, from the field to the moment of being in. */
|
|
24
|
+
export declare function signInTour(page?: string): Tour;
|
|
25
|
+
/** The account page's package panels: the usage table, the persona, coupons, the session key. */
|
|
26
|
+
export declare function accountPanelsSteps(): TourStep[];
|
|
27
|
+
/** The account page as the package knows it; a site appends its own steps. */
|
|
28
|
+
export declare function accountTour(page?: string, own?: TourStep[]): Tour;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two tours every site shares, because the pages are the package's: the
|
|
3
|
+
* sign-in gate, and the account page's panels. A site puts them in its tour
|
|
4
|
+
* map beside its own, and adds its own steps after the panels' for whatever
|
|
5
|
+
* else its account page holds.
|
|
6
|
+
*
|
|
7
|
+
* The copy says what each thing is, in the words the network uses — Patron,
|
|
8
|
+
* npub, MCP tool, api_sats — in a sentence or two.
|
|
9
|
+
*/
|
|
10
|
+
/** The `data-tour` names the package's own markup carries. */
|
|
11
|
+
export const TB_TARGETS = {
|
|
12
|
+
gateNpub: "tb.gate.npub",
|
|
13
|
+
gateLinks: "tb.gate.links",
|
|
14
|
+
gateSend: "tb.gate.send",
|
|
15
|
+
gateSteps: "tb.gate.steps",
|
|
16
|
+
gateVerify: "tb.gate.verify",
|
|
17
|
+
accountProfile: "tb.account.profile",
|
|
18
|
+
accountUsage: "tb.account.usage",
|
|
19
|
+
accountCoupons: "tb.account.coupons",
|
|
20
|
+
accountSessionKey: "tb.account.sessionKey",
|
|
21
|
+
};
|
|
22
|
+
/** The sign-in gate, from the field to the moment of being in. */
|
|
23
|
+
export function signInTour(page = "signin") {
|
|
24
|
+
const steps = [
|
|
25
|
+
{ target: TB_TARGETS.gateNpub, title: "Your npub",
|
|
26
|
+
says: "Paste your npub here. An nsec works too and stays in this browser." },
|
|
27
|
+
{ target: TB_TARGETS.gateLinks, title: "Get a key",
|
|
28
|
+
says: "Get a free key in 0xchat or Pricing Studio. Either makes one in a minute.", side: "top" },
|
|
29
|
+
{ target: TB_TARGETS.gateSend, title: "Send",
|
|
30
|
+
says: "We send a message to your Nostr client. Reply to it with any words." },
|
|
31
|
+
{ target: TB_TARGETS.gateSteps, title: "The proof",
|
|
32
|
+
says: "Your signed reply is the proof. The tiles show where you are.", side: "top" },
|
|
33
|
+
{ target: TB_TARGETS.gateVerify, title: "Verify",
|
|
34
|
+
says: "Replied? Tap here and you are in." },
|
|
35
|
+
];
|
|
36
|
+
return { page, steps };
|
|
37
|
+
}
|
|
38
|
+
/** The account page's package panels: the usage table, the persona, coupons, the session key. */
|
|
39
|
+
export function accountPanelsSteps() {
|
|
40
|
+
return [
|
|
41
|
+
{ target: TB_TARGETS.accountUsage, title: "What each tool drew",
|
|
42
|
+
says: "Every MCP tool you have called, how many times, and the api_sats each cost. The operator sets the prices; this is what was paid.", side: "top" },
|
|
43
|
+
{ target: TB_TARGETS.accountProfile, title: "Your Nostr persona",
|
|
44
|
+
says: "The name, picture, bio and addresses Nostr shows for your npub. Tap Edit to change them; they publish to your Nostr profile.", side: "top" },
|
|
45
|
+
{ target: TB_TARGETS.accountCoupons, title: "Coupons",
|
|
46
|
+
says: "A code an operator hands out. Redeem it once here and it discounts your MCP tool fees on its own after that.", side: "top" },
|
|
47
|
+
{ target: TB_TARGETS.accountSessionKey, title: "Your session key",
|
|
48
|
+
says: "This browser holds a key that signs each call for you. Claim it here to carry it to another device.", side: "top" },
|
|
49
|
+
];
|
|
50
|
+
}
|
|
51
|
+
/** The account page as the package knows it; a site appends its own steps. */
|
|
52
|
+
export function accountTour(page = "account", own = []) {
|
|
53
|
+
return { page, steps: [...accountPanelsSteps(), ...own] };
|
|
54
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of a page's guided tour: a handful of steps, each pointing at one
|
|
3
|
+
* control and saying what it is for.
|
|
4
|
+
*
|
|
5
|
+
* Data with no DOM in it, so a site's copy can be tested: every target drawn,
|
|
6
|
+
* every sentence saying what a thing IS. The mechanics that run it are in
|
|
7
|
+
* `./run.ts` and `react/useTour.ts`; the words are each site's own, except
|
|
8
|
+
* for the two pages every site shares (`./tours.ts`).
|
|
9
|
+
*/
|
|
10
|
+
export interface TourStep {
|
|
11
|
+
/**
|
|
12
|
+
* A `data-tour` name on the element, or — for markup a site does not own —
|
|
13
|
+
* a function that finds it. A function that finds nothing skips the step.
|
|
14
|
+
*/
|
|
15
|
+
target: string | (() => Element | null);
|
|
16
|
+
title: string;
|
|
17
|
+
/** One or two short sentences: what the control is, what to do with it. */
|
|
18
|
+
says: string;
|
|
19
|
+
side?: "top" | "right" | "bottom" | "left";
|
|
20
|
+
align?: "start" | "center" | "end";
|
|
21
|
+
/**
|
|
22
|
+
* Something the page must do before this step can be pointed at — open the
|
|
23
|
+
* add form, pick a tag. The page registers the action by this name.
|
|
24
|
+
*/
|
|
25
|
+
before?: string;
|
|
26
|
+
/** The guide step this abbreviates, for a site's own test: [group, control]. */
|
|
27
|
+
guide?: [group: string, control: string];
|
|
28
|
+
}
|
|
29
|
+
export interface Tour {
|
|
30
|
+
/** The page's key, as the site names its views. */
|
|
31
|
+
page: string;
|
|
32
|
+
steps: TourStep[];
|
|
33
|
+
}
|
|
34
|
+
/** What a step may ask the page to do first, by name. */
|
|
35
|
+
export type TourActions = Record<string, () => void | Promise<void>>;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of a page's guided tour: a handful of steps, each pointing at one
|
|
3
|
+
* control and saying what it is for.
|
|
4
|
+
*
|
|
5
|
+
* Data with no DOM in it, so a site's copy can be tested: every target drawn,
|
|
6
|
+
* every sentence saying what a thing IS. The mechanics that run it are in
|
|
7
|
+
* `./run.ts` and `react/useTour.ts`; the words are each site's own, except
|
|
8
|
+
* for the two pages every site shares (`./tours.ts`).
|
|
9
|
+
*/
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guided tour's preference, stored per site and per device under
|
|
3
|
+
* `<prefix>:tour:v1`: whether the live tutorial is on, which pages have had
|
|
4
|
+
* their tour, and a page asked to replay once. The rules are in
|
|
5
|
+
* `tour/state.ts`; this is their storage, in the shape `timezone.ts` set.
|
|
6
|
+
*/
|
|
7
|
+
import { type TourPrefs } from "./tour/state.ts";
|
|
8
|
+
/** `<prefix>:tour:v1` — for a `storage` event listener. */
|
|
9
|
+
export declare function tourStorageKey(): string;
|
|
10
|
+
export declare function readTourPrefs(): TourPrefs;
|
|
11
|
+
/** Store the preference and tell every listener on the page. */
|
|
12
|
+
export declare function writeTourPrefs(next: TourPrefs): TourPrefs;
|
|
13
|
+
/** Hear every `writeTourPrefs` on this page. Returns an unsubscribe. */
|
|
14
|
+
export declare function onTourChange(fn: (p: TourPrefs) => void): () => void;
|
|
15
|
+
/**
|
|
16
|
+
* Write the preference down if nothing is stored yet. A newcomer is read as
|
|
17
|
+
* "on" while they hold no npub; the moment they sign in they would read as
|
|
18
|
+
* an old hand and the tutorial would go quiet mid-welcome. Settling the
|
|
19
|
+
* first reading keeps a newcomer a newcomer.
|
|
20
|
+
*/
|
|
21
|
+
export declare function settleTourPrefs(): TourPrefs;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guided tour's preference, stored per site and per device under
|
|
3
|
+
* `<prefix>:tour:v1`: whether the live tutorial is on, which pages have had
|
|
4
|
+
* their tour, and a page asked to replay once. The rules are in
|
|
5
|
+
* `tour/state.ts`; this is their storage, in the shape `timezone.ts` set.
|
|
6
|
+
*/
|
|
7
|
+
import { getStoredNpub } from "./identity.js";
|
|
8
|
+
import { key, readStored, writeStored } from "./storage.js";
|
|
9
|
+
import { fromStored } from "./tour/state.js";
|
|
10
|
+
const PREF = "tour:v1";
|
|
11
|
+
/** `<prefix>:tour:v1` — for a `storage` event listener. */
|
|
12
|
+
export function tourStorageKey() {
|
|
13
|
+
return key(PREF);
|
|
14
|
+
}
|
|
15
|
+
// The last raw value read and what it parsed to. `useSyncExternalStore`
|
|
16
|
+
// wants the same object back while nothing changed; a fresh parse each read
|
|
17
|
+
// would be a new object every time and a render that never settles.
|
|
18
|
+
let lastRaw = null;
|
|
19
|
+
let lastOldHand = false;
|
|
20
|
+
let lastPrefs = null;
|
|
21
|
+
export function readTourPrefs() {
|
|
22
|
+
const raw = readStored(PREF);
|
|
23
|
+
// A device that already holds a patron npub but no tour preference has
|
|
24
|
+
// been here since before the tour existed, and is left in peace. Read each
|
|
25
|
+
// time: signing in changes it.
|
|
26
|
+
const oldHand = raw === "" && !!getStoredNpub();
|
|
27
|
+
if (lastPrefs && raw === lastRaw && oldHand === lastOldHand)
|
|
28
|
+
return lastPrefs;
|
|
29
|
+
let parsed = null;
|
|
30
|
+
if (raw) {
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(raw);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
parsed = null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
lastPrefs = fromStored(parsed, oldHand);
|
|
39
|
+
lastRaw = raw;
|
|
40
|
+
lastOldHand = oldHand;
|
|
41
|
+
return lastPrefs;
|
|
42
|
+
}
|
|
43
|
+
const listeners = new Set();
|
|
44
|
+
/** Store the preference and tell every listener on the page. */
|
|
45
|
+
export function writeTourPrefs(next) {
|
|
46
|
+
writeStored(PREF, JSON.stringify(next));
|
|
47
|
+
lastRaw = null;
|
|
48
|
+
const stored = readTourPrefs();
|
|
49
|
+
listeners.forEach((fn) => fn(stored));
|
|
50
|
+
return stored;
|
|
51
|
+
}
|
|
52
|
+
/** Hear every `writeTourPrefs` on this page. Returns an unsubscribe. */
|
|
53
|
+
export function onTourChange(fn) {
|
|
54
|
+
listeners.add(fn);
|
|
55
|
+
return () => {
|
|
56
|
+
listeners.delete(fn);
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Write the preference down if nothing is stored yet. A newcomer is read as
|
|
61
|
+
* "on" while they hold no npub; the moment they sign in they would read as
|
|
62
|
+
* an old hand and the tutorial would go quiet mid-welcome. Settling the
|
|
63
|
+
* first reading keeps a newcomer a newcomer.
|
|
64
|
+
*/
|
|
65
|
+
export function settleTourPrefs() {
|
|
66
|
+
const current = readTourPrefs();
|
|
67
|
+
if (readStored(PREF) === "")
|
|
68
|
+
writeTourPrefs(current);
|
|
69
|
+
return current;
|
|
70
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tollbooth-dpyc/web",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Shared browser SDK for Tollbooth DPYC operator front ends: npub sign-in, proofs, MCP client, profile and wallet",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Lonnie VanZandt",
|
|
@@ -25,11 +25,13 @@
|
|
|
25
25
|
"types": "./dist/pagesProxy.d.ts",
|
|
26
26
|
"default": "./dist/pagesProxy.js"
|
|
27
27
|
},
|
|
28
|
-
"./theme.css": "./theme.css"
|
|
28
|
+
"./theme.css": "./theme.css",
|
|
29
|
+
"./tour.css": "./tour.css"
|
|
29
30
|
},
|
|
30
31
|
"files": [
|
|
31
32
|
"dist",
|
|
32
33
|
"theme.css",
|
|
34
|
+
"tour.css",
|
|
33
35
|
"README.md",
|
|
34
36
|
"LICENSE"
|
|
35
37
|
],
|
|
@@ -55,5 +57,8 @@
|
|
|
55
57
|
},
|
|
56
58
|
"publishConfig": {
|
|
57
59
|
"access": "public"
|
|
60
|
+
},
|
|
61
|
+
"dependencies": {
|
|
62
|
+
"driver.js": "1.9.0"
|
|
58
63
|
}
|
|
59
64
|
}
|
package/tour.css
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The guided tour's popover, in the site's own tokens.
|
|
3
|
+
*
|
|
4
|
+
* driver.js draws it; these rules give it the site's surface, ink and line
|
|
5
|
+
* through the --tb-* variables theme.css defines, and the structure the
|
|
6
|
+
* tour adds: the "Stop the tutorial" link under the buttons, and the Yes/No
|
|
7
|
+
* pair on the offer. Colours are the site's; a site may restyle `.tb-tour`
|
|
8
|
+
* further (the title's font, say) in its own stylesheet.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
@import "driver.js/dist/driver.css";
|
|
12
|
+
|
|
13
|
+
.driver-popover.tb-tour {
|
|
14
|
+
font-family: inherit;
|
|
15
|
+
color: var(--tb-ink);
|
|
16
|
+
background-color: var(--tb-surface);
|
|
17
|
+
border: 1px solid var(--tb-line);
|
|
18
|
+
border-radius: 10px;
|
|
19
|
+
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.18);
|
|
20
|
+
max-width: min(320px, calc(100vw - 24px));
|
|
21
|
+
padding: 14px 16px 12px;
|
|
22
|
+
}
|
|
23
|
+
.tb-tour .driver-popover-title {
|
|
24
|
+
font-size: 17px;
|
|
25
|
+
font-weight: 600;
|
|
26
|
+
line-height: 1.3;
|
|
27
|
+
color: var(--tb-ink);
|
|
28
|
+
}
|
|
29
|
+
.tb-tour .driver-popover-description {
|
|
30
|
+
margin-top: 6px;
|
|
31
|
+
font-size: 13.5px;
|
|
32
|
+
line-height: 1.45;
|
|
33
|
+
color: var(--tb-ink);
|
|
34
|
+
}
|
|
35
|
+
.tb-tour .driver-popover-close-btn {
|
|
36
|
+
top: 2px;
|
|
37
|
+
right: 2px;
|
|
38
|
+
width: 36px;
|
|
39
|
+
height: 36px;
|
|
40
|
+
color: var(--tb-muted);
|
|
41
|
+
}
|
|
42
|
+
.tb-tour .driver-popover-close-btn:hover,
|
|
43
|
+
.tb-tour .driver-popover-close-btn:focus { color: var(--tb-ink); }
|
|
44
|
+
.tb-tour .driver-popover-footer { margin-top: 12px; gap: 8px; }
|
|
45
|
+
.tb-tour .driver-popover-progress-text {
|
|
46
|
+
font-size: 11px;
|
|
47
|
+
color: var(--tb-muted);
|
|
48
|
+
}
|
|
49
|
+
.tb-tour .driver-popover-footer-btn,
|
|
50
|
+
.tb-tour .tb-tour-yes,
|
|
51
|
+
.tb-tour .tb-tour-no {
|
|
52
|
+
font-family: inherit;
|
|
53
|
+
font-size: 12.5px;
|
|
54
|
+
font-weight: 500;
|
|
55
|
+
min-height: 36px;
|
|
56
|
+
padding: 0 14px;
|
|
57
|
+
border-radius: 999px;
|
|
58
|
+
border: 1.5px solid var(--tb-line);
|
|
59
|
+
background-color: var(--tb-surface);
|
|
60
|
+
color: var(--tb-muted);
|
|
61
|
+
cursor: pointer;
|
|
62
|
+
}
|
|
63
|
+
.tb-tour .driver-popover-next-btn,
|
|
64
|
+
.tb-tour .tb-tour-yes {
|
|
65
|
+
border-color: var(--tb-accent);
|
|
66
|
+
background-color: var(--tb-accent);
|
|
67
|
+
color: var(--tb-on-accent);
|
|
68
|
+
}
|
|
69
|
+
.tb-tour .driver-popover-footer-btn:hover,
|
|
70
|
+
.tb-tour .driver-popover-footer-btn:focus,
|
|
71
|
+
.tb-tour .tb-tour-no:hover,
|
|
72
|
+
.tb-tour .tb-tour-no:focus { background-color: var(--tb-surface-2); }
|
|
73
|
+
.tb-tour .driver-popover-next-btn:hover,
|
|
74
|
+
.tb-tour .driver-popover-next-btn:focus,
|
|
75
|
+
.tb-tour .tb-tour-yes:hover,
|
|
76
|
+
.tb-tour .tb-tour-yes:focus { background-color: var(--tb-accent); }
|
|
77
|
+
.tb-tour .tb-tour-stop {
|
|
78
|
+
display: block;
|
|
79
|
+
margin-top: 10px;
|
|
80
|
+
padding: 0;
|
|
81
|
+
border: 0;
|
|
82
|
+
background: none;
|
|
83
|
+
font-family: inherit;
|
|
84
|
+
font-size: 11.5px;
|
|
85
|
+
color: var(--tb-muted);
|
|
86
|
+
text-decoration: underline;
|
|
87
|
+
text-decoration-style: dotted;
|
|
88
|
+
text-underline-offset: 2px;
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
}
|
|
91
|
+
.tb-tour .tb-tour-answer {
|
|
92
|
+
display: flex;
|
|
93
|
+
justify-content: flex-end;
|
|
94
|
+
gap: 8px;
|
|
95
|
+
margin-top: 14px;
|
|
96
|
+
}
|
|
97
|
+
/* The offer has no element to point at; it sits in the middle of the page. */
|
|
98
|
+
.tb-tour.tb-tour-offer .driver-popover-arrow { display: none; }
|
|
99
|
+
/* The arrow takes the surface colour on whichever side it points from. */
|
|
100
|
+
.tb-tour .driver-popover-arrow-side-left { border-left-color: var(--tb-surface); }
|
|
101
|
+
.tb-tour .driver-popover-arrow-side-right { border-right-color: var(--tb-surface); }
|
|
102
|
+
.tb-tour .driver-popover-arrow-side-top { border-top-color: var(--tb-surface); }
|
|
103
|
+
.tb-tour .driver-popover-arrow-side-bottom { border-bottom-color: var(--tb-surface); }
|