@rightkit/onboarding 0.3.3 → 0.6.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 +4 -2
- package/dist/Onboarding.d.ts +1 -1
- package/dist/Onboarding.js +72 -16
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/steps.d.ts +23 -2
- package/dist/steps.js +34 -5
- package/dist/types.d.ts +73 -2
- package/package.json +17 -16
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@ First-run onboarding framework for Right Suite apps. The legal agreement gate co
|
|
|
13
13
|
|
|
14
14
|
| Import | Contents |
|
|
15
15
|
|---|---|
|
|
16
|
-
| `@rightkit/onboarding` | `Onboarding`, `isLegalAccepted`, `onboardingStateKey`, `licenseStep`, `modelsStep`, `permissionsStep
|
|
16
|
+
| `@rightkit/onboarding` | `Onboarding`, `isLegalAccepted`, `onboardingStateKey`, `licenseStep`, `modelsStep`, `permissionsStep`, `signInStep` (Google/Apple/OAuth buttons that call the shared `rightkit-oauth` owner; copy says "Opens your browser"; never an in-app webview, bogusyogi/rightkit#279); types from `types.ts`, `ModelsStepOptions` |
|
|
17
17
|
| `@rightkit/onboarding/onboarding.css` | Stylesheet |
|
|
18
18
|
|
|
19
19
|
## Peer dependencies
|
|
@@ -43,7 +43,9 @@ import "@rightkit/onboarding/onboarding.css";
|
|
|
43
43
|
/>
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Free or open-source apps without a legal gate pass `legalMode="none"` (no legal step, `legal` not needed, `appName` for the rail) or `legalMode="notice"` (no gate or acceptance; a first Notice step lists `legal.documents` as links). The default is `"gate"`.
|
|
47
|
+
|
|
48
|
+
Acceptance is stored as `legalAcceptance: { version, documentsHash, acceptedAt, eligibilityBasis }`. An app that already keeps its own acceptance receipt can pass `legalReceipt` (`read()` and `write(record)`) and keep that store as the source of truth. Steps can set `canContinue(context)`, `onContinue(context)` (a veto: `false` or a throw keeps the step), `continueLabel`, and `skippable` (a per-step Skip button). `ctx.setResult(value)` stores a value that `onComplete({ completed, skipped, steps })` receives. `labels` overrides the button and status text, and `licenseStep(backend, { copy })` overrides the licence copy.
|
|
47
49
|
|
|
48
50
|
## When the legal gate re-prompts
|
|
49
51
|
|
package/dist/Onboarding.d.ts
CHANGED
|
@@ -2,4 +2,4 @@ import type { OnboardingProps, PersistedOnboardingState } from "./types.js";
|
|
|
2
2
|
/** True when the stored acceptance satisfies the host's requirement. */
|
|
3
3
|
export declare function isLegalAccepted(state: PersistedOnboardingState, requiredVersion?: string, custom?: (state: PersistedOnboardingState) => boolean, requiredDocumentsHash?: string): boolean;
|
|
4
4
|
export declare function onboardingStateKey(): string;
|
|
5
|
-
export declare function Onboarding({ settings, legal, steps, permissions, models, license, onComplete, onSkip, className, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip, titlebar, dragRegion, legalDragRegion, legalReceipt }: OnboardingProps): import("react").JSX.Element;
|
|
5
|
+
export declare function Onboarding({ settings, legal, legalMode, appName, steps, permissions, models, license, onComplete, onSkip, className, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip, titlebar, dragRegion, legalDragRegion, legalReceipt, labels: labelOverrides }: OnboardingProps): import("react").JSX.Element | null;
|
package/dist/Onboarding.js
CHANGED
|
@@ -6,6 +6,7 @@ import { cx } from "./ui.js";
|
|
|
6
6
|
import { licenseStep, modelsStep, permissionsStep } from "./steps.js";
|
|
7
7
|
const KEY = "onboarding.state";
|
|
8
8
|
const INITIAL = { version: 1, currentStep: 0, completed: [], skipped: false, legalAccepted: false };
|
|
9
|
+
const DEFAULT_LABELS = { skip: "Set up later", skipStep: "Skip", back: "Back", continue: "Continue", finish: "Finish setup", loading: "Loading setup…" };
|
|
9
10
|
function readAcceptance(value) {
|
|
10
11
|
if (!value || typeof value !== "object")
|
|
11
12
|
return undefined;
|
|
@@ -29,6 +30,7 @@ function readState(value) {
|
|
|
29
30
|
legalAccepted: candidate.legalAccepted === true || legalAcceptance !== undefined,
|
|
30
31
|
eligibilityBasis: legalAcceptance?.eligibilityBasis ?? (candidate.eligibilityBasis === "individual" || candidate.eligibilityBasis === "enterprise" ? candidate.eligibilityBasis : undefined),
|
|
31
32
|
...(legalAcceptance ? { legalAcceptance } : {}),
|
|
33
|
+
...(Array.isArray(candidate.skippedSteps) ? { skippedSteps: candidate.skippedSteps.filter((x) => typeof x === "string") } : {}),
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
36
|
/** Replace the acceptance in `state` with the one from the app's receipt (`undefined`: not accepted). */
|
|
@@ -79,7 +81,18 @@ function TitleStrip({ titlebar, dragRegion }) {
|
|
|
79
81
|
return null;
|
|
80
82
|
return _jsx("div", { className: "rk-onboarding__titlebar", "data-testid": "onboarding-titlebar", ...(dragRegion ? { "data-tauri-drag-region": "" } : {}), children: titlebar });
|
|
81
83
|
}
|
|
82
|
-
|
|
84
|
+
function NoticeList({ documents }) {
|
|
85
|
+
if (!documents.length)
|
|
86
|
+
return _jsx("p", { className: "rk-onboarding__muted", children: "No notices to show." });
|
|
87
|
+
return _jsx("ul", { className: "rk-onboarding__notices", children: documents.map((d) => _jsx("li", { children: d.onOpen ? _jsx("button", { type: "button", onClick: d.onOpen, children: d.title }) : d.url ? _jsx("a", { href: d.url, target: "_blank", rel: "noreferrer", children: d.title }) : d.title }, d.title)) });
|
|
88
|
+
}
|
|
89
|
+
export function Onboarding({ settings, legal, legalMode = "gate", appName, steps = [], permissions, models, license, onComplete, onSkip, className, requiredVersion, requiredDocumentsHash, isAccepted, documentsHash, beforeLegalGate, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro, allowSkip = true, titlebar, dragRegion, legalDragRegion = false, legalReceipt, labels: labelOverrides }) {
|
|
90
|
+
if (legalMode === "gate" && !legal)
|
|
91
|
+
throw new Error("Onboarding: `legal` is required when legalMode is \"gate\"");
|
|
92
|
+
const L = { ...DEFAULT_LABELS, ...labelOverrides };
|
|
93
|
+
const gated = legalMode === "gate";
|
|
94
|
+
/** Leading steps the user cannot navigate to: the Agreement stand-in in gate mode. */
|
|
95
|
+
const lead = gated ? 1 : 0;
|
|
83
96
|
const [state, setState] = useState(null);
|
|
84
97
|
const [saving, setSaving] = useState(false);
|
|
85
98
|
const [error, setError] = useState(null);
|
|
@@ -87,6 +100,9 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
|
|
|
87
100
|
const [gateCleared, setGateCleared] = useState(false);
|
|
88
101
|
/** One auto-start per mount, even when the models step unmounts (Back/Next) or the step list is rebuilt. */
|
|
89
102
|
const modelsStarted = useRef(false);
|
|
103
|
+
/** Values passed to `ctx.setResult`, keyed by step id; handed to `onComplete`. */
|
|
104
|
+
const stepResults = useRef({});
|
|
105
|
+
const [continuing, setContinuing] = useState(false);
|
|
90
106
|
/** Persists a patch; throws when the settings backend rejects it. */
|
|
91
107
|
const persist = useCallback(async (patch) => {
|
|
92
108
|
setSaving(true);
|
|
@@ -144,20 +160,24 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
|
|
|
144
160
|
return () => { live = false; };
|
|
145
161
|
}, [settings, legalReceipt]);
|
|
146
162
|
const builtins = useMemo(() => [
|
|
147
|
-
{ id: "legal", label: "Agreement", title: "Review agreement", skippable: false, render: () => _jsx("p", { className: "rk-onboarding__muted", children: "Agreement accepted." }) },
|
|
163
|
+
...(gated ? [{ id: "legal", label: "Agreement", title: "Review agreement", skippable: false, render: () => _jsx("p", { className: "rk-onboarding__muted", children: "Agreement accepted." }) }] : []),
|
|
164
|
+
...(legalMode === "notice" ? [{ id: "notice", label: "Notice", title: "Notices", skippable: false, render: () => _jsx(NoticeList, { documents: legal?.documents ?? [] }) }] : []),
|
|
148
165
|
...(permissions ? [permissionsStep(permissions)] : []),
|
|
149
166
|
...(models ? [modelsStep(models, { required: requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, intro: modelsIntro, startGuard: modelsStarted })] : []),
|
|
150
167
|
...(license ? [licenseStep(license)] : []),
|
|
151
168
|
...steps,
|
|
152
|
-
], [license, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
|
|
169
|
+
], [gated, legalMode, legal?.documents, license, models, permissions, steps, requireModel, autoStartDownload, shouldAutoStart, onModelsStepEnter, modelsIntro]);
|
|
153
170
|
const activeState = state ?? INITIAL;
|
|
154
|
-
const accepted = isLegalAccepted(activeState, requiredVersion, isAccepted, requiredDocumentsHash);
|
|
171
|
+
const accepted = !gated || isLegalAccepted(activeState, requiredVersion, isAccepted, requiredDocumentsHash);
|
|
155
172
|
const needsGate = state !== null && !accepted;
|
|
156
173
|
// The agreement gate does not depend on the saved step: until it is accepted, the active step is the first.
|
|
157
174
|
// Once accepted, the Agreement step (index 0) is never shown again when other steps exist.
|
|
158
|
-
const index = !accepted ? 0 : builtins.length >
|
|
175
|
+
const index = !accepted ? 0 : builtins.length > lead ? Math.max(lead, Math.min(activeState.currentStep, builtins.length - 1)) : 0;
|
|
159
176
|
const active = builtins[index];
|
|
160
177
|
const activeId = active?.id ?? "";
|
|
178
|
+
/** The step on screen now; an `onContinue` that resolves after the step changed is stale. */
|
|
179
|
+
const activeIdRef = useRef(activeId);
|
|
180
|
+
activeIdRef.current = activeId;
|
|
161
181
|
const setReady = useCallback((ready) => setReadyMap((m) => m[activeId] === ready ? m : { ...m, [activeId]: ready }), [activeId]);
|
|
162
182
|
useEffect(() => {
|
|
163
183
|
if (!needsGate) {
|
|
@@ -177,7 +197,8 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
|
|
|
177
197
|
setGateCleared(true); })();
|
|
178
198
|
return () => { live = false; };
|
|
179
199
|
}, [needsGate, gateCleared, beforeLegalGate]);
|
|
180
|
-
const
|
|
200
|
+
const setResult = useCallback((value) => { stepResults.current[activeId] = value; }, [activeId]);
|
|
201
|
+
const context = { state: activeState, settings, setState: saveStep, permissions, models, license, ready: readyMap, setReady, setResult };
|
|
181
202
|
const canContinue = active?.canContinue ? active.canContinue(context) : true;
|
|
182
203
|
const runCallback = async (callback) => {
|
|
183
204
|
try {
|
|
@@ -187,22 +208,57 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
|
|
|
187
208
|
setError(e instanceof Error ? e.message : String(e));
|
|
188
209
|
}
|
|
189
210
|
};
|
|
190
|
-
const
|
|
191
|
-
|
|
211
|
+
const resultOf = (completed, skipped) => ({ completed, skipped, steps: { ...stepResults.current } });
|
|
212
|
+
/** Marks `mark.completed` done or `mark.skipped` skipped (never both), then moves to `nextIndex`; completing the last step calls `onComplete`. */
|
|
213
|
+
const markAndMove = async (nextIndex, mark = {}) => {
|
|
214
|
+
const previousSkipped = activeState.skippedSteps ?? [];
|
|
215
|
+
// A step is completed or skipped, never both: a skip removes an earlier completion of the same step.
|
|
216
|
+
const completedBase = activeState.completed.filter((id) => id !== mark.skipped);
|
|
217
|
+
const completed = mark.completed && !completedBase.includes(mark.completed) ? [...completedBase, mark.completed] : completedBase;
|
|
218
|
+
const skippedBase = previousSkipped.filter((id) => id !== mark.completed);
|
|
219
|
+
const skippedSteps = mark.skipped && !skippedBase.includes(mark.skipped) ? [...skippedBase, mark.skipped] : skippedBase;
|
|
192
220
|
if (nextIndex >= builtins.length) {
|
|
193
|
-
if (await save({ completed, currentStep: Math.max(0, builtins.length - 1), skipped: false }))
|
|
194
|
-
await runCallback(onComplete);
|
|
221
|
+
if (await save({ completed, skippedSteps, currentStep: Math.max(0, builtins.length - 1), skipped: false }))
|
|
222
|
+
await runCallback(() => onComplete(resultOf(completed, skippedSteps)));
|
|
195
223
|
return;
|
|
196
224
|
}
|
|
197
|
-
await save({ completed, currentStep: nextIndex, skipped: false });
|
|
225
|
+
await save({ completed, skippedSteps, currentStep: nextIndex, skipped: false });
|
|
226
|
+
};
|
|
227
|
+
/** Primary button: runs the step's `onContinue` veto first; `false` or a throw keeps the step. */
|
|
228
|
+
const advance = async () => {
|
|
229
|
+
if (active.onContinue) {
|
|
230
|
+
const stepId = active.id;
|
|
231
|
+
setContinuing(true);
|
|
232
|
+
setError(null);
|
|
233
|
+
let proceed = true;
|
|
234
|
+
try {
|
|
235
|
+
proceed = (await active.onContinue(context)) !== false;
|
|
236
|
+
}
|
|
237
|
+
catch (e) {
|
|
238
|
+
if (activeIdRef.current === stepId)
|
|
239
|
+
setError(e instanceof Error ? e.message : String(e));
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
setContinuing(false);
|
|
244
|
+
}
|
|
245
|
+
// The step changed while onContinue was awaited: discard this completion rather than move or save from stale state.
|
|
246
|
+
if (!proceed || activeIdRef.current !== stepId)
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
await markAndMove(index + 1, { completed: active.id });
|
|
198
250
|
};
|
|
199
251
|
if (error && !state)
|
|
200
252
|
return _jsxs("div", { className: cx("rk-onboarding", className), children: [_jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }), _jsx(Button, { onClick: () => window.location.reload(), children: "Retry" })] });
|
|
253
|
+
if (state && !active)
|
|
254
|
+
return _jsxs("main", { className: cx("rk-onboarding", className), "data-testid": "onboarding", children: [_jsx("p", { className: "rk-onboarding__muted", children: "Nothing to set up." }), _jsx(Button, { variant: "primary", onClick: () => void runCallback(() => onComplete(resultOf(activeState.completed, activeState.skippedSteps ?? []))), children: L.finish })] });
|
|
201
255
|
if (!state || !active)
|
|
202
|
-
return _jsx("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: _jsx("p", { className: "rk-onboarding__muted", children:
|
|
256
|
+
return _jsx("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: _jsx("p", { className: "rk-onboarding__muted", children: L.loading }) });
|
|
203
257
|
if (needsGate) {
|
|
204
258
|
if (!gateCleared)
|
|
205
|
-
return _jsxs("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx("p", { className: "rk-onboarding__muted", children:
|
|
259
|
+
return _jsxs("div", { className: cx("rk-onboarding", className), "aria-busy": "true", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx("p", { className: "rk-onboarding__muted", children: L.loading })] });
|
|
260
|
+
if (!legal)
|
|
261
|
+
return null;
|
|
206
262
|
const reacceptance = activeState.legalAccepted || activeState.legalAcceptance !== undefined;
|
|
207
263
|
return _jsxs("div", { className: cx("rk-onboarding rk-onboarding--legal", className), "data-testid": "onboarding-legal", "data-variant": reacceptance ? "re-acceptance" : "first-run", children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: legalDragRegion }), _jsx(LegalGate, { ...legal, variant: reacceptance ? "re-acceptance" : "first-run", onQuit: () => void legal.onQuit?.(), onAgree: async ({ eligibilityBasis }) => {
|
|
208
264
|
const legalAcceptance = { version: requiredVersion ?? "1", documentsHash: documentsHash ?? requiredDocumentsHash ?? await hashDocuments(legal.documents), acceptedAt: new Date().toISOString(), eligibilityBasis };
|
|
@@ -218,10 +274,10 @@ export function Onboarding({ settings, legal, steps = [], permissions, models, l
|
|
|
218
274
|
const progress = reacceptance ? {} : { completed: ["legal"], currentStep: builtins.length > 1 ? 1 : 0 };
|
|
219
275
|
await persist({ legalAccepted: true, eligibilityBasis, legalAcceptance, ...progress });
|
|
220
276
|
if (!reacceptance && builtins.length === 1)
|
|
221
|
-
await onComplete();
|
|
277
|
+
await onComplete(resultOf(["legal"], []));
|
|
222
278
|
} })] });
|
|
223
279
|
}
|
|
224
280
|
const body = active.render(context);
|
|
225
|
-
return _jsxs("main", { className: cx("rk-onboarding", className), "data-testid": "onboarding", "data-step-id": active.id, children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: dragRegion }), _jsxs("aside", { className: "rk-onboarding__rail", children: [_jsx("div", { className: "rk-onboarding__brand", children: legal
|
|
226
|
-
void save({ currentStep: i }); }, children: [_jsx("span", { children: activeState.completed.includes(step.id) ? "✓" : i + 1 }), step.label] }, step.id)) })] }), _jsxs("section", { className: "rk-onboarding__main", children: [_jsxs("header", { children: [_jsxs("p", { className: "rk-onboarding__eyebrow", children: ["Step ", index + 1, " of ", builtins.length] }), _jsx("h1", { children: active.title }), active.description ? _jsx("p", { className: "rk-onboarding__description", children: active.description }) : null] }), _jsx("div", { className: "rk-onboarding__content", children: body }), error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, _jsxs("footer", { children: [allowSkip ? _jsx(Button, { variant: "ghost", disabled: saving, onClick: () => { void save({ currentStep: index, skipped: true }).then((ok) => ok ? runCallback(onSkip) : undefined); }, children:
|
|
281
|
+
return _jsxs("main", { className: cx("rk-onboarding", className), "data-testid": "onboarding", "data-step-id": active.id, children: [_jsx(TitleStrip, { titlebar: titlebar, dragRegion: dragRegion }), _jsxs("aside", { className: "rk-onboarding__rail", children: [_jsx("div", { className: "rk-onboarding__brand", children: appName ?? legal?.appName }), _jsx("nav", { "aria-label": "Setup progress", children: builtins.map((step, i) => _jsxs("button", { type: "button", className: cx("rk-onboarding__step", i === index && "is-active", activeState.completed.includes(step.id) && "is-done"), "aria-current": i === index ? "step" : undefined, disabled: (gated && i === 0 && builtins.length > 1) || continuing, onClick: () => { if (!continuing && i >= lead && (i <= index || canContinue))
|
|
282
|
+
void save({ currentStep: i }); }, children: [_jsx("span", { children: activeState.completed.includes(step.id) ? "✓" : i + 1 }), step.label] }, step.id)) })] }), _jsxs("section", { className: "rk-onboarding__main", children: [_jsxs("header", { children: [_jsxs("p", { className: "rk-onboarding__eyebrow", children: ["Step ", index + 1, " of ", builtins.length] }), active.title ? _jsx("h1", { children: active.title }) : null, active.description ? _jsx("p", { className: "rk-onboarding__description", children: active.description }) : null] }), _jsx("div", { className: "rk-onboarding__content", children: body }), error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, _jsxs("footer", { children: [allowSkip ? _jsx(Button, { variant: "ghost", disabled: saving || continuing, onClick: () => { void save({ currentStep: index, skipped: true }).then((ok) => ok ? runCallback(onSkip) : undefined); }, children: L.skip }) : null, active.skippable ? _jsx(Button, { variant: "ghost", disabled: saving || continuing, onClick: () => void markAndMove(index + 1, { skipped: active.id }), children: L.skipStep }) : null, _jsx("span", { className: "rk-onboarding__hint", children: !canContinue && active.blockedHint ? active.blockedHint : null }), _jsx(Button, { variant: "secondary", disabled: index <= lead || saving || continuing, onClick: () => void save({ currentStep: index - 1 }), children: L.back }), _jsx(Button, { variant: "primary", disabled: saving || continuing || !canContinue, onClick: () => void advance(), children: active.continueLabel ?? (index === builtins.length - 1 ? L.finish : L.continue) })] })] })] });
|
|
227
283
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
|
|
2
|
-
export { licenseStep, modelsStep, permissionsStep } from "./steps.js";
|
|
2
|
+
export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
|
|
3
3
|
export type { ModelsStepOptions } from "./steps.js";
|
|
4
4
|
export type * from "./types.js";
|
package/dist/index.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export { Onboarding, isLegalAccepted, onboardingStateKey } from "./Onboarding.js";
|
|
2
|
-
export { licenseStep, modelsStep, permissionsStep } from "./steps.js";
|
|
2
|
+
export { licenseStep, modelsStep, permissionsStep, signInStep } from "./steps.js";
|
package/dist/steps.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
|
-
import type { LicenseBackend, ModelsAutoStart, ModelsBackend, OnboardingStep, PermissionsBackend } from "./types.js";
|
|
2
|
+
import type { SignInBackend, LicenseBackend, ModelsAutoStart, ModelsBackend, OnboardingStep, PermissionsBackend } from "./types.js";
|
|
3
3
|
export declare function permissionsStep(backend: PermissionsBackend, options?: {
|
|
4
4
|
label?: string;
|
|
5
5
|
title?: string;
|
|
@@ -21,7 +21,28 @@ export interface ModelsStepOptions extends ModelsAutoStart {
|
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
23
|
export declare function modelsStep(backend: ModelsBackend, options?: ModelsStepOptions): OnboardingStep;
|
|
24
|
-
|
|
24
|
+
/** Copy overrides for the licence step. Each key defaults to the English text. */
|
|
25
|
+
export interface LicenseStepCopy {
|
|
26
|
+
/** Key field label and placeholder. Default "Licence key". */
|
|
27
|
+
keyLabel?: string;
|
|
28
|
+
/** Submit button. Default "Activate". */
|
|
29
|
+
activateLabel?: string;
|
|
30
|
+
/** Trial button. Default "Start trial". */
|
|
31
|
+
trialLabel?: string;
|
|
32
|
+
/** Plans link. Default "View plans". */
|
|
33
|
+
plansLabel?: string;
|
|
34
|
+
/** Status text when no licence is active. Default "You can continue without a licence.". */
|
|
35
|
+
noLicenceDetail?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface LicenseStepOptions {
|
|
38
|
+
label?: string;
|
|
39
|
+
title?: string;
|
|
40
|
+
description?: string;
|
|
41
|
+
copy?: LicenseStepCopy;
|
|
42
|
+
}
|
|
43
|
+
export declare function licenseStep(backend: LicenseBackend, options?: LicenseStepOptions): OnboardingStep;
|
|
44
|
+
/** Sign-in step. Every button hands off to the shared owner and opens the user's default browser; no in-app webview. */
|
|
45
|
+
export declare function signInStep(backend: SignInBackend, options?: {
|
|
25
46
|
label?: string;
|
|
26
47
|
title?: string;
|
|
27
48
|
description?: string;
|
package/dist/steps.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
import { Button, Input, Badge } from "@rightkit/react-shell/react";
|
|
4
4
|
function ProgressBar({ value, label }) {
|
|
@@ -100,16 +100,45 @@ export function modelsStep(backend, options = {}) {
|
|
|
100
100
|
render: (ctx) => _jsx(ModelsBody, { backend: backend, onReady: ctx.setReady, autoStartDownload: options.autoStartDownload, shouldAutoStart: options.shouldAutoStart, onModelsStepEnter: options.onModelsStepEnter, intro: options.intro, startGuard: options.startGuard }),
|
|
101
101
|
};
|
|
102
102
|
}
|
|
103
|
-
function LicenseBody({ backend }) {
|
|
103
|
+
function LicenseBody({ backend, copy }) {
|
|
104
104
|
const [state, setState] = useState({ status: "none" });
|
|
105
105
|
const [key, setKey] = useState("");
|
|
106
106
|
const [busy, setBusy] = useState(false);
|
|
107
107
|
const [error, setError] = useState(null);
|
|
108
108
|
useEffect(() => void backend.load().then(setState, (e) => setError(e instanceof Error ? e.message : String(e))), [backend]);
|
|
109
109
|
const run = (fn) => { setBusy(true); setError(null); void fn().then(setState, (e) => setError(e instanceof Error ? e.message : String(e))).finally(() => setBusy(false)); };
|
|
110
|
-
return _jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-license", children: [_jsxs("div", { className: "rk-onboarding__license-state", children: [_jsx(Badge, { tone: state.status === "active" || state.status === "trial" ? "ok" : state.status === "expired" || state.status === "invalid" ? "bad" : "neutral", children: state.label ?? state.status }), _jsx("span", { children: state.detail ??
|
|
111
|
-
run(() => backend.activate(key.trim())); }, children: [_jsx(Input, { "aria-label":
|
|
110
|
+
return _jsxs("div", { className: "rk-onboarding__card", "data-testid": "onboarding-license", children: [_jsxs("div", { className: "rk-onboarding__license-state", children: [_jsx(Badge, { tone: state.status === "active" || state.status === "trial" ? "ok" : state.status === "expired" || state.status === "invalid" ? "bad" : "neutral", children: state.label ?? state.status }), _jsx("span", { children: state.detail ?? copy.noLicenceDetail })] }), error ? _jsx("p", { className: "rk-onboarding__error", role: "alert", children: error }) : null, backend.activate ? _jsxs("form", { className: "rk-onboarding__form", onSubmit: (e) => { e.preventDefault(); if (key.trim())
|
|
111
|
+
run(() => backend.activate(key.trim())); }, children: [_jsx(Input, { "aria-label": copy.keyLabel, placeholder: copy.keyLabel, value: key, onChange: (e) => setKey(e.target.value), disabled: busy }), _jsx(Button, { size: "sm", variant: "primary", type: "submit", disabled: busy || !key.trim(), children: copy.activateLabel })] }) : null, _jsxs("div", { className: "rk-onboarding__row-actions", children: [backend.startTrial ? _jsx(Button, { size: "sm", variant: "secondary", disabled: busy, onClick: () => run(() => backend.startTrial()), children: copy.trialLabel }) : null, backend.purchaseUrl ? _jsx(Button, { size: "sm", variant: "ghost", onClick: () => void backend.openExternal?.(backend.purchaseUrl), children: copy.plansLabel }) : null] })] });
|
|
112
112
|
}
|
|
113
113
|
export function licenseStep(backend, options = {}) {
|
|
114
|
-
|
|
114
|
+
const copy = {
|
|
115
|
+
keyLabel: "Licence key", activateLabel: "Activate", trialLabel: "Start trial", plansLabel: "View plans",
|
|
116
|
+
noLicenceDetail: "You can continue without a licence.", ...options.copy,
|
|
117
|
+
};
|
|
118
|
+
return { id: "license", label: options.label ?? "Licence", title: options.title ?? "Choose how you’ll use this app", description: options.description ?? "Activate a licence, start a trial, or continue with the free plan.", skippable: true, render: () => _jsx(LicenseBody, { backend: backend, copy: copy }) };
|
|
119
|
+
}
|
|
120
|
+
function SignInBody({ backend }) {
|
|
121
|
+
const [account, setAccount] = useState({ signedIn: false });
|
|
122
|
+
const [busy, setBusy] = useState(null);
|
|
123
|
+
const [error, setError] = useState(null);
|
|
124
|
+
useEffect(() => { let live = true; backend.load().then((a) => { if (live)
|
|
125
|
+
setAccount(a); }, () => undefined); return () => { live = false; }; }, [backend]);
|
|
126
|
+
const start = async (provider) => {
|
|
127
|
+
setBusy(provider);
|
|
128
|
+
setError(null);
|
|
129
|
+
try {
|
|
130
|
+
setAccount(await backend.start(provider));
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
setError(e instanceof Error ? e.message : "Sign-in did not finish.");
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
setBusy(null);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
return _jsxs("div", { className: "rk-onboarding__signin", children: [account.signedIn ? _jsxs("p", { children: ["Signed in", account.account ? _jsxs(_Fragment, { children: [" as ", _jsx("strong", { children: account.account })] }) : null, "."] }) : null, _jsx("div", { className: "rk-onboarding__row-actions", children: backend.providers.map((p) => _jsx(Button, { size: "sm", variant: "secondary", disabled: busy !== null, onClick: () => void start(p.id), children: busy === p.id ? "Waiting for your browser…" : p.label ?? `Sign in with ${p.id === "google" ? "Google" : p.id === "apple" ? "Apple" : "your account"}` }, p.id)) }), _jsx("p", { className: "rk-onboarding__hint", children: _jsx("small", { children: "Opens your browser. You stay signed in to the accounts you already use there." }) }), error ? _jsx("p", { role: "alert", children: error }) : null] });
|
|
140
|
+
}
|
|
141
|
+
/** Sign-in step. Every button hands off to the shared owner and opens the user's default browser; no in-app webview. */
|
|
142
|
+
export function signInStep(backend, options = {}) {
|
|
143
|
+
return { id: "sign-in", label: options.label ?? "Sign in", title: options.title ?? "Sign in", description: options.description ?? "Sign-in opens your default browser, so you stay signed in to the accounts you already use there.", skippable: true, render: () => _jsx(SignInBody, { backend: backend }) };
|
|
115
144
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -57,6 +57,28 @@ export interface LicenseBackend {
|
|
|
57
57
|
purchaseUrl?: string;
|
|
58
58
|
openExternal?: (url: string) => void | Promise<void>;
|
|
59
59
|
}
|
|
60
|
+
export type SignInProvider = "google" | "apple" | "oauth";
|
|
61
|
+
/**
|
|
62
|
+
* Sign-in owned by `rightkit-oauth`. Every provider opens the user's DEFAULT browser (never an in-app webview, so existing
|
|
63
|
+
* browser sessions are kept); `start` must call the shared owner (`OAuthClient::sign_in_with_browser` through the app's
|
|
64
|
+
* command), not open a window of its own. Orthic-Labs/rightkit#279.
|
|
65
|
+
*/
|
|
66
|
+
export interface SignInBackend {
|
|
67
|
+
providers: readonly {
|
|
68
|
+
id: SignInProvider;
|
|
69
|
+
label?: string;
|
|
70
|
+
}[];
|
|
71
|
+
/** Account already signed in, if any. */
|
|
72
|
+
load(): Promise<{
|
|
73
|
+
signedIn: boolean;
|
|
74
|
+
account?: string;
|
|
75
|
+
}>;
|
|
76
|
+
/** Open the default browser for `provider` and resolve when the sign-in finishes (or reject / throw on failure or cancel). */
|
|
77
|
+
start(provider: SignInProvider): Promise<{
|
|
78
|
+
signedIn: boolean;
|
|
79
|
+
account?: string;
|
|
80
|
+
}>;
|
|
81
|
+
}
|
|
60
82
|
export type EligibilityBasis = "individual" | "enterprise";
|
|
61
83
|
/** Full record of one legal acceptance, persisted so version changes can re-prompt. */
|
|
62
84
|
export interface LegalAcceptanceRecord {
|
|
@@ -86,6 +108,29 @@ export interface PersistedOnboardingState {
|
|
|
86
108
|
/** Legacy mirror of `legalAcceptance.eligibilityBasis`. */
|
|
87
109
|
eligibilityBasis?: EligibilityBasis;
|
|
88
110
|
legalAcceptance?: LegalAcceptanceRecord;
|
|
111
|
+
/** Step ids the user skipped with the per-step Skip button (not marked complete). */
|
|
112
|
+
skippedSteps?: string[];
|
|
113
|
+
}
|
|
114
|
+
/** What `onComplete` receives. `steps` holds values passed to `ctx.setResult` during this mount. */
|
|
115
|
+
export interface OnboardingResult {
|
|
116
|
+
completed: string[];
|
|
117
|
+
skipped: string[];
|
|
118
|
+
steps: Record<string, unknown>;
|
|
119
|
+
}
|
|
120
|
+
/** Button and status copy. Each key defaults to the English text shown in the table. */
|
|
121
|
+
export interface OnboardingLabels {
|
|
122
|
+
/** "Set up later". */
|
|
123
|
+
skip: string;
|
|
124
|
+
/** Per-step Skip button. */
|
|
125
|
+
skipStep: string;
|
|
126
|
+
/** "Back". */
|
|
127
|
+
back: string;
|
|
128
|
+
/** "Continue". */
|
|
129
|
+
continue: string;
|
|
130
|
+
/** "Finish setup" on the last step. */
|
|
131
|
+
finish: string;
|
|
132
|
+
/** "Loading setup…". */
|
|
133
|
+
loading: string;
|
|
89
134
|
}
|
|
90
135
|
export interface OnboardingStepContext {
|
|
91
136
|
state: PersistedOnboardingState;
|
|
@@ -94,10 +139,13 @@ export interface OnboardingStepContext {
|
|
|
94
139
|
permissions?: PermissionsBackend;
|
|
95
140
|
models?: ModelsBackend;
|
|
96
141
|
license?: LicenseBackend;
|
|
142
|
+
signIn?: SignInBackend;
|
|
97
143
|
/** Readiness reported by steps via `setReady`, keyed by step id. */
|
|
98
144
|
ready: Readonly<Record<string, boolean>>;
|
|
99
145
|
/** Reports whether the active step is ready (used by `canContinue`). Stable per step. */
|
|
100
146
|
setReady: (ready: boolean) => void;
|
|
147
|
+
/** Stores a value for this step; it is passed to `onComplete` as `result.steps[step.id]`. */
|
|
148
|
+
setResult: (value: unknown) => void;
|
|
101
149
|
}
|
|
102
150
|
export interface OnboardingStep {
|
|
103
151
|
id: string;
|
|
@@ -109,21 +157,44 @@ export interface OnboardingStep {
|
|
|
109
157
|
canContinue?: (context: OnboardingStepContext) => boolean;
|
|
110
158
|
/** Shown beside the disabled Continue/Finish button. */
|
|
111
159
|
blockedHint?: string;
|
|
160
|
+
/** Primary button text for this step (e.g. "Open CodeRight"). Overrides Continue and Finish setup. */
|
|
161
|
+
continueLabel?: string;
|
|
162
|
+
/**
|
|
163
|
+
* Runs before Continue/Finish advances. Return `false` (or throw) to keep the step; a throw shows its message.
|
|
164
|
+
* `void` or `true` advances. Not run for Back, the rail, or Skip.
|
|
165
|
+
*/
|
|
166
|
+
onContinue?: (context: OnboardingStepContext) => void | boolean | Promise<void | boolean>;
|
|
167
|
+
/** Empty title hides the heading (e.g. a Welcome step). */
|
|
112
168
|
render: (context: OnboardingStepContext) => ReactNode;
|
|
113
169
|
}
|
|
114
170
|
export interface LegalStepConfig extends Omit<LegalGateProps, "variant" | "onAgree" | "onQuit"> {
|
|
115
171
|
onQuit?: () => void;
|
|
116
172
|
}
|
|
173
|
+
/**
|
|
174
|
+
* How legal documents take part in onboarding.
|
|
175
|
+
* - `gate` (default): the blocking agreement gate comes first and is recorded (needs `legal`).
|
|
176
|
+
* - `notice`: no gate and no acceptance. A first "Notice" step lists the `legal` documents as links; Continue moves on (free or open-source apps that still hold their notice).
|
|
177
|
+
* - `none`: no legal step at all; `legal` is not needed.
|
|
178
|
+
*/
|
|
179
|
+
export type LegalMode = "gate" | "notice" | "none";
|
|
117
180
|
export interface OnboardingProps {
|
|
118
181
|
settings: SettingsBackend;
|
|
119
|
-
|
|
182
|
+
/** Required when `legalMode` is `gate` (default). Optional for `notice` (the documents shown) and `none`. */
|
|
183
|
+
legal?: LegalStepConfig;
|
|
184
|
+
/** Default `gate`. See `LegalMode`. */
|
|
185
|
+
legalMode?: LegalMode;
|
|
186
|
+
/** Name shown in the step rail. Default: `legal.appName`. */
|
|
187
|
+
appName?: string;
|
|
120
188
|
/** App-specific steps are appended after built-in steps, in supplied order. */
|
|
121
189
|
steps?: readonly OnboardingStep[];
|
|
122
190
|
permissions?: PermissionsBackend;
|
|
123
191
|
models?: ModelsBackend;
|
|
124
192
|
license?: LicenseBackend;
|
|
125
|
-
|
|
193
|
+
/** Called once when setup finishes, with the completed and skipped step ids and the step results. */
|
|
194
|
+
onComplete: (result: OnboardingResult) => void | Promise<void>;
|
|
126
195
|
onSkip?: () => void | Promise<void>;
|
|
196
|
+
/** Override any button or status text. Keys not given keep the English defaults. */
|
|
197
|
+
labels?: Partial<OnboardingLabels>;
|
|
127
198
|
className?: string;
|
|
128
199
|
/** Version the user must have accepted. A different (or missing) stored version re-prompts with `variant: "re-acceptance"`. */
|
|
129
200
|
requiredVersion?: string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rightkit/onboarding",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "First-run onboarding framework: ordered steps, progress, skip/resume, legal gate first, permissions, model download and licence/trial steps.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -29,20 +29,21 @@
|
|
|
29
29
|
"react": ">=18 <21"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@rightkit/react-shell": "0.
|
|
33
|
-
"@rightkit/legal-ui": "0.
|
|
34
|
-
"@rightkit/qa-chrome": "0.1.
|
|
35
|
-
"@testing-library/jest-dom": "^
|
|
36
|
-
"@testing-library/
|
|
37
|
-
"@testing-library/
|
|
38
|
-
"@
|
|
39
|
-
"@types/react
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"react
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
32
|
+
"@rightkit/react-shell": "0.7.0",
|
|
33
|
+
"@rightkit/legal-ui": "0.4.0",
|
|
34
|
+
"@rightkit/qa-chrome": "0.1.8",
|
|
35
|
+
"@testing-library/jest-dom": "^7.0.1",
|
|
36
|
+
"@testing-library/dom": "^10.4.2",
|
|
37
|
+
"@testing-library/react": "^16.3.3",
|
|
38
|
+
"@testing-library/user-event": "^14.6.8",
|
|
39
|
+
"@types/react": "^19.3.0",
|
|
40
|
+
"@types/react-dom": "^19.3.0",
|
|
41
|
+
"jsdom": "^30.1.3",
|
|
42
|
+
"react": "^19.3.1",
|
|
43
|
+
"react-dom": "^19.3.1",
|
|
44
|
+
"typescript": "^7.0.2",
|
|
45
|
+
"vite": "^8.3.4",
|
|
46
|
+
"vitest": "^5.0.5"
|
|
46
47
|
},
|
|
47
48
|
"publishConfig": {
|
|
48
49
|
"registry": "https://registry.npmjs.org",
|
|
@@ -50,7 +51,7 @@
|
|
|
50
51
|
},
|
|
51
52
|
"repository": {
|
|
52
53
|
"type": "git",
|
|
53
|
-
"url": "git+https://github.com/
|
|
54
|
+
"url": "git+https://github.com/bogusyogi/rightkit.git",
|
|
54
55
|
"directory": "packages/onboarding"
|
|
55
56
|
},
|
|
56
57
|
"scripts": {
|