@korsolutions/guidon 1.0.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 +334 -0
- package/dist/index-D_JFvCIg.d.mts +314 -0
- package/dist/index-D_JFvCIg.d.ts +314 -0
- package/dist/index.d.mts +128 -0
- package/dist/index.d.ts +128 -0
- package/dist/index.js +1098 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1073 -0
- package/dist/index.mjs.map +1 -0
- package/dist/persistence/index.d.mts +2 -0
- package/dist/persistence/index.d.ts +2 -0
- package/dist/persistence/index.js +300 -0
- package/dist/persistence/index.js.map +1 -0
- package/dist/persistence/index.mjs +291 -0
- package/dist/persistence/index.mjs.map +1 -0
- package/package.json +76 -0
- package/src/components/GuidonOverlay.tsx +159 -0
- package/src/components/GuidonProvider.tsx +158 -0
- package/src/components/GuidonTarget.tsx +108 -0
- package/src/components/GuidonTooltip.tsx +365 -0
- package/src/components/index.ts +4 -0
- package/src/index.ts +51 -0
- package/src/persistence/adapters.ts +224 -0
- package/src/persistence/hooks.ts +179 -0
- package/src/persistence/index.ts +2 -0
- package/src/store.ts +268 -0
- package/src/types.ts +242 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import type { GuidonPersistenceAdapter, GuidonProgress } from '../types';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY_PREFIX = '@guidon:';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* No-op adapter that doesn't persist anything
|
|
7
|
+
* Useful for testing or when persistence is not needed
|
|
8
|
+
*/
|
|
9
|
+
export const createNoopAdapter = (): GuidonPersistenceAdapter => ({
|
|
10
|
+
loadProgress: async () => null,
|
|
11
|
+
saveProgress: async () => {},
|
|
12
|
+
loadAllProgress: async () => ({}),
|
|
13
|
+
clearProgress: async () => {},
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Memory adapter that stores progress in memory
|
|
18
|
+
* Data is lost when the app is closed
|
|
19
|
+
*/
|
|
20
|
+
export const createMemoryAdapter = (): GuidonPersistenceAdapter => {
|
|
21
|
+
const store: Record<string, GuidonProgress> = {};
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
loadProgress: async (guidonId) => store[guidonId] ?? null,
|
|
25
|
+
saveProgress: async (progress) => {
|
|
26
|
+
store[progress.guidonId] = progress;
|
|
27
|
+
},
|
|
28
|
+
loadAllProgress: async () => ({ ...store }),
|
|
29
|
+
clearProgress: async (guidonId) => {
|
|
30
|
+
delete store[guidonId];
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* localStorage adapter for web
|
|
37
|
+
* Only works in browser environments
|
|
38
|
+
*/
|
|
39
|
+
export const createLocalStorageAdapter = (
|
|
40
|
+
keyPrefix: string = STORAGE_KEY_PREFIX
|
|
41
|
+
): GuidonPersistenceAdapter => ({
|
|
42
|
+
loadProgress: async (guidonId) => {
|
|
43
|
+
if (typeof window === 'undefined' || !window.localStorage) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const data = localStorage.getItem(`${keyPrefix}${guidonId}`);
|
|
48
|
+
return data ? JSON.parse(data) : null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
saveProgress: async (progress) => {
|
|
54
|
+
if (typeof window === 'undefined' || !window.localStorage) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
localStorage.setItem(
|
|
59
|
+
`${keyPrefix}${progress.guidonId}`,
|
|
60
|
+
JSON.stringify(progress)
|
|
61
|
+
);
|
|
62
|
+
} catch {
|
|
63
|
+
// Silently fail if storage is full
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
loadAllProgress: async () => {
|
|
67
|
+
if (typeof window === 'undefined' || !window.localStorage) {
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
const result: Record<string, GuidonProgress> = {};
|
|
71
|
+
try {
|
|
72
|
+
for (let i = 0; i < localStorage.length; i++) {
|
|
73
|
+
const key = localStorage.key(i);
|
|
74
|
+
if (key?.startsWith(keyPrefix)) {
|
|
75
|
+
const data = localStorage.getItem(key);
|
|
76
|
+
if (data) {
|
|
77
|
+
const progress = JSON.parse(data) as GuidonProgress;
|
|
78
|
+
result[progress.guidonId] = progress;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Silently fail
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
},
|
|
87
|
+
clearProgress: async (guidonId) => {
|
|
88
|
+
if (typeof window === 'undefined' || !window.localStorage) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
localStorage.removeItem(`${keyPrefix}${guidonId}`);
|
|
93
|
+
} catch {
|
|
94
|
+
// Silently fail
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* AsyncStorage adapter for React Native
|
|
101
|
+
* Requires @react-native-async-storage/async-storage to be installed
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
105
|
+
* const adapter = createAsyncStorageAdapter(AsyncStorage);
|
|
106
|
+
*/
|
|
107
|
+
export const createAsyncStorageAdapter = (
|
|
108
|
+
asyncStorage: {
|
|
109
|
+
getItem: (key: string) => Promise<string | null>;
|
|
110
|
+
setItem: (key: string, value: string) => Promise<void>;
|
|
111
|
+
removeItem: (key: string) => Promise<void>;
|
|
112
|
+
getAllKeys: () => Promise<readonly string[]>;
|
|
113
|
+
multiGet: (keys: readonly string[]) => Promise<readonly [string, string | null][]>;
|
|
114
|
+
},
|
|
115
|
+
keyPrefix: string = STORAGE_KEY_PREFIX
|
|
116
|
+
): GuidonPersistenceAdapter => ({
|
|
117
|
+
loadProgress: async (guidonId) => {
|
|
118
|
+
try {
|
|
119
|
+
const data = await asyncStorage.getItem(`${keyPrefix}${guidonId}`);
|
|
120
|
+
return data ? JSON.parse(data) : null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
saveProgress: async (progress) => {
|
|
126
|
+
try {
|
|
127
|
+
await asyncStorage.setItem(
|
|
128
|
+
`${keyPrefix}${progress.guidonId}`,
|
|
129
|
+
JSON.stringify(progress)
|
|
130
|
+
);
|
|
131
|
+
} catch {
|
|
132
|
+
// Silently fail
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
loadAllProgress: async () => {
|
|
136
|
+
const result: Record<string, GuidonProgress> = {};
|
|
137
|
+
try {
|
|
138
|
+
const allKeys = await asyncStorage.getAllKeys();
|
|
139
|
+
const relevantKeys = allKeys.filter((key) => key.startsWith(keyPrefix));
|
|
140
|
+
const pairs = await asyncStorage.multiGet(relevantKeys);
|
|
141
|
+
for (const [, value] of pairs) {
|
|
142
|
+
if (value) {
|
|
143
|
+
const progress = JSON.parse(value) as GuidonProgress;
|
|
144
|
+
result[progress.guidonId] = progress;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
} catch {
|
|
148
|
+
// Silently fail
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
},
|
|
152
|
+
clearProgress: async (guidonId) => {
|
|
153
|
+
try {
|
|
154
|
+
await asyncStorage.removeItem(`${keyPrefix}${guidonId}`);
|
|
155
|
+
} catch {
|
|
156
|
+
// Silently fail
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a custom API adapter for backend persistence
|
|
163
|
+
* This is a factory function that creates an adapter based on your API endpoints
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* const adapter = createApiAdapter({
|
|
167
|
+
* loadProgress: async (guidonId) => {
|
|
168
|
+
* const response = await fetch(`/api/guidon/${guidonId}`);
|
|
169
|
+
* return response.json();
|
|
170
|
+
* },
|
|
171
|
+
* saveProgress: async (progress) => {
|
|
172
|
+
* await fetch(`/api/guidon/${progress.guidonId}`, {
|
|
173
|
+
* method: 'POST',
|
|
174
|
+
* body: JSON.stringify(progress),
|
|
175
|
+
* });
|
|
176
|
+
* },
|
|
177
|
+
* });
|
|
178
|
+
*/
|
|
179
|
+
export const createApiAdapter = (
|
|
180
|
+
handlers: Partial<GuidonPersistenceAdapter>
|
|
181
|
+
): GuidonPersistenceAdapter => {
|
|
182
|
+
const noopAdapter = createNoopAdapter();
|
|
183
|
+
return {
|
|
184
|
+
loadProgress: handlers.loadProgress ?? noopAdapter.loadProgress,
|
|
185
|
+
saveProgress: handlers.saveProgress ?? noopAdapter.saveProgress,
|
|
186
|
+
loadAllProgress: handlers.loadAllProgress ?? noopAdapter.loadAllProgress,
|
|
187
|
+
clearProgress: handlers.clearProgress ?? noopAdapter.clearProgress,
|
|
188
|
+
};
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Combine multiple adapters (e.g., save to both local and API)
|
|
193
|
+
* Loads from the first adapter that returns data
|
|
194
|
+
* Saves to all adapters
|
|
195
|
+
*/
|
|
196
|
+
export const createCompositeAdapter = (
|
|
197
|
+
adapters: GuidonPersistenceAdapter[]
|
|
198
|
+
): GuidonPersistenceAdapter => ({
|
|
199
|
+
loadProgress: async (guidonId) => {
|
|
200
|
+
for (const adapter of adapters) {
|
|
201
|
+
const progress = await adapter.loadProgress(guidonId);
|
|
202
|
+
if (progress) return progress;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
},
|
|
206
|
+
saveProgress: async (progress) => {
|
|
207
|
+
await Promise.all(adapters.map((adapter) => adapter.saveProgress(progress)));
|
|
208
|
+
},
|
|
209
|
+
loadAllProgress: async () => {
|
|
210
|
+
const result: Record<string, GuidonProgress> = {};
|
|
211
|
+
for (const adapter of adapters) {
|
|
212
|
+
if (adapter.loadAllProgress) {
|
|
213
|
+
const data = await adapter.loadAllProgress();
|
|
214
|
+
Object.assign(result, data);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return result;
|
|
218
|
+
},
|
|
219
|
+
clearProgress: async (guidonId) => {
|
|
220
|
+
await Promise.all(
|
|
221
|
+
adapters.map((adapter) => adapter.clearProgress?.(guidonId))
|
|
222
|
+
);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import type { GuidonPersistenceAdapter, GuidonProgress } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook to manage guidon's walkthrough progress with a persistence adapter
|
|
6
|
+
*/
|
|
7
|
+
export function useGuidonPersistence(
|
|
8
|
+
adapter: GuidonPersistenceAdapter | undefined,
|
|
9
|
+
guidonId: string
|
|
10
|
+
) {
|
|
11
|
+
const [progress, setProgress] = useState<GuidonProgress | null>(null);
|
|
12
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
|
|
15
|
+
// Load progress on mount
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
if (!adapter) {
|
|
18
|
+
setIsLoading(false);
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let mounted = true;
|
|
23
|
+
|
|
24
|
+
const loadProgress = async () => {
|
|
25
|
+
try {
|
|
26
|
+
setIsLoading(true);
|
|
27
|
+
setError(null);
|
|
28
|
+
const data = await adapter.loadProgress(guidonId);
|
|
29
|
+
if (mounted) {
|
|
30
|
+
setProgress(data);
|
|
31
|
+
}
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (mounted) {
|
|
34
|
+
setError(err instanceof Error ? err.message : 'Failed to load progress');
|
|
35
|
+
}
|
|
36
|
+
} finally {
|
|
37
|
+
if (mounted) {
|
|
38
|
+
setIsLoading(false);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
loadProgress();
|
|
44
|
+
|
|
45
|
+
return () => {
|
|
46
|
+
mounted = false;
|
|
47
|
+
};
|
|
48
|
+
}, [adapter, guidonId]);
|
|
49
|
+
|
|
50
|
+
const saveProgress = useCallback(
|
|
51
|
+
async (newProgress: Omit<GuidonProgress, 'guidonId'>) => {
|
|
52
|
+
if (!adapter) return;
|
|
53
|
+
|
|
54
|
+
const fullProgress: GuidonProgress = {
|
|
55
|
+
...newProgress,
|
|
56
|
+
guidonId,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
setError(null);
|
|
61
|
+
await adapter.saveProgress(fullProgress);
|
|
62
|
+
setProgress(fullProgress);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
setError(err instanceof Error ? err.message : 'Failed to save progress');
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
[adapter, guidonId]
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const clearProgress = useCallback(async () => {
|
|
72
|
+
if (!adapter?.clearProgress) return;
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
setError(null);
|
|
76
|
+
await adapter.clearProgress(guidonId);
|
|
77
|
+
setProgress(null);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
setError(err instanceof Error ? err.message : 'Failed to clear progress');
|
|
80
|
+
throw err;
|
|
81
|
+
}
|
|
82
|
+
}, [adapter, guidonId]);
|
|
83
|
+
|
|
84
|
+
const markCompleted = useCallback(async () => {
|
|
85
|
+
const currentCount = progress?.completionCount ?? 0;
|
|
86
|
+
await saveProgress({
|
|
87
|
+
completed: true,
|
|
88
|
+
completedAt: new Date().toISOString(),
|
|
89
|
+
completionCount: currentCount + 1,
|
|
90
|
+
});
|
|
91
|
+
}, [saveProgress, progress?.completionCount]);
|
|
92
|
+
|
|
93
|
+
const markStepViewed = useCallback(
|
|
94
|
+
async (stepIndex: number) => {
|
|
95
|
+
await saveProgress({
|
|
96
|
+
completed: progress?.completed ?? false,
|
|
97
|
+
lastStepIndex: stepIndex,
|
|
98
|
+
completedAt: progress?.completedAt,
|
|
99
|
+
completionCount: progress?.completionCount,
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
[saveProgress, progress]
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
progress,
|
|
107
|
+
isLoading,
|
|
108
|
+
error,
|
|
109
|
+
isCompleted: progress?.completed ?? false,
|
|
110
|
+
hasStarted: progress !== null,
|
|
111
|
+
saveProgress,
|
|
112
|
+
clearProgress,
|
|
113
|
+
markCompleted,
|
|
114
|
+
markStepViewed,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Hook to check if a guidon should be shown
|
|
120
|
+
*/
|
|
121
|
+
export function useShouldShowGuidon(
|
|
122
|
+
adapter: GuidonPersistenceAdapter | undefined,
|
|
123
|
+
guidonId: string,
|
|
124
|
+
options?: {
|
|
125
|
+
/** Show even if completed (for replay) */
|
|
126
|
+
forceShow?: boolean;
|
|
127
|
+
/** Additional condition to check */
|
|
128
|
+
additionalCondition?: () => boolean | Promise<boolean>;
|
|
129
|
+
}
|
|
130
|
+
) {
|
|
131
|
+
const { progress, isLoading } = useGuidonPersistence(adapter, guidonId);
|
|
132
|
+
const [shouldShow, setShouldShow] = useState(false);
|
|
133
|
+
const [isChecking, setIsChecking] = useState(true);
|
|
134
|
+
|
|
135
|
+
useEffect(() => {
|
|
136
|
+
if (isLoading) return;
|
|
137
|
+
|
|
138
|
+
const checkCondition = async () => {
|
|
139
|
+
setIsChecking(true);
|
|
140
|
+
|
|
141
|
+
// If forceShow is true, always show
|
|
142
|
+
if (options?.forceShow) {
|
|
143
|
+
setShouldShow(true);
|
|
144
|
+
setIsChecking(false);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// If already completed, don't show
|
|
149
|
+
if (progress?.completed) {
|
|
150
|
+
setShouldShow(false);
|
|
151
|
+
setIsChecking(false);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Check additional condition if provided
|
|
156
|
+
if (options?.additionalCondition) {
|
|
157
|
+
try {
|
|
158
|
+
const result = await options.additionalCondition();
|
|
159
|
+
setShouldShow(result);
|
|
160
|
+
} catch {
|
|
161
|
+
setShouldShow(true); // Default to showing on error
|
|
162
|
+
}
|
|
163
|
+
} else {
|
|
164
|
+
// Default: show if not completed
|
|
165
|
+
setShouldShow(true);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setIsChecking(false);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
checkCondition();
|
|
172
|
+
}, [isLoading, progress?.completed, options?.forceShow, options?.additionalCondition]);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
shouldShow,
|
|
176
|
+
isChecking: isLoading || isChecking,
|
|
177
|
+
isCompleted: progress?.completed ?? false,
|
|
178
|
+
};
|
|
179
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import type {
|
|
3
|
+
GuidonConfig,
|
|
4
|
+
GuidonStore,
|
|
5
|
+
TargetMeasurements,
|
|
6
|
+
} from "./types";
|
|
7
|
+
|
|
8
|
+
const initialState = {
|
|
9
|
+
config: null,
|
|
10
|
+
isActive: false,
|
|
11
|
+
currentStepIndex: 0,
|
|
12
|
+
isCompleted: false,
|
|
13
|
+
targetMeasurements: {},
|
|
14
|
+
isLoading: false,
|
|
15
|
+
error: null,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export const useGuidonStore = create<GuidonStore>((set, get) => ({
|
|
19
|
+
...initialState,
|
|
20
|
+
|
|
21
|
+
configure: (config: GuidonConfig) => {
|
|
22
|
+
set({ config, currentStepIndex: 0, isCompleted: false });
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
start: () => {
|
|
26
|
+
const { config } = get();
|
|
27
|
+
if (!config || config.steps.length === 0) return;
|
|
28
|
+
|
|
29
|
+
set({ isActive: true, currentStepIndex: 0, isCompleted: false });
|
|
30
|
+
|
|
31
|
+
// Call onStepEnter for the first step
|
|
32
|
+
const firstStep = config.steps[0];
|
|
33
|
+
firstStep.onStepEnter?.();
|
|
34
|
+
config.onStepChange?.(0, firstStep);
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
next: () => {
|
|
38
|
+
const { config, currentStepIndex, isActive } = get();
|
|
39
|
+
if (!config || !isActive) return;
|
|
40
|
+
|
|
41
|
+
const currentStep = config.steps[currentStepIndex];
|
|
42
|
+
currentStep?.onStepExit?.();
|
|
43
|
+
|
|
44
|
+
if (currentStepIndex < config.steps.length - 1) {
|
|
45
|
+
const nextIndex = currentStepIndex + 1;
|
|
46
|
+
const nextStep = config.steps[nextIndex];
|
|
47
|
+
|
|
48
|
+
set({ currentStepIndex: nextIndex });
|
|
49
|
+
|
|
50
|
+
nextStep.onStepEnter?.();
|
|
51
|
+
config.onStepChange?.(nextIndex, nextStep);
|
|
52
|
+
} else {
|
|
53
|
+
// Last step completed
|
|
54
|
+
get().complete();
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
previous: () => {
|
|
59
|
+
const { config, currentStepIndex, isActive } = get();
|
|
60
|
+
if (!config || !isActive || currentStepIndex === 0) return;
|
|
61
|
+
|
|
62
|
+
const currentStep = config.steps[currentStepIndex];
|
|
63
|
+
currentStep?.onStepExit?.();
|
|
64
|
+
|
|
65
|
+
const prevIndex = currentStepIndex - 1;
|
|
66
|
+
const prevStep = config.steps[prevIndex];
|
|
67
|
+
|
|
68
|
+
set({ currentStepIndex: prevIndex });
|
|
69
|
+
|
|
70
|
+
prevStep.onStepEnter?.();
|
|
71
|
+
config.onStepChange?.(prevIndex, prevStep);
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
goToStep: (index: number) => {
|
|
75
|
+
const { config, currentStepIndex, isActive } = get();
|
|
76
|
+
if (!config || !isActive) return;
|
|
77
|
+
if (index < 0 || index >= config.steps.length) return;
|
|
78
|
+
|
|
79
|
+
const currentStep = config.steps[currentStepIndex];
|
|
80
|
+
currentStep?.onStepExit?.();
|
|
81
|
+
|
|
82
|
+
const targetStep = config.steps[index];
|
|
83
|
+
|
|
84
|
+
set({ currentStepIndex: index });
|
|
85
|
+
|
|
86
|
+
targetStep.onStepEnter?.();
|
|
87
|
+
config.onStepChange?.(index, targetStep);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
skip: () => {
|
|
91
|
+
const { config, currentStepIndex } = get();
|
|
92
|
+
if (!config) return;
|
|
93
|
+
|
|
94
|
+
const currentStep = config.steps[currentStepIndex];
|
|
95
|
+
currentStep?.onStepExit?.();
|
|
96
|
+
|
|
97
|
+
set({ isActive: false, isCompleted: false });
|
|
98
|
+
config.onSkip?.();
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
complete: () => {
|
|
102
|
+
const { config, currentStepIndex } = get();
|
|
103
|
+
if (!config) return;
|
|
104
|
+
|
|
105
|
+
const currentStep = config.steps[currentStepIndex];
|
|
106
|
+
currentStep?.onStepExit?.();
|
|
107
|
+
|
|
108
|
+
set({ isActive: false, isCompleted: true });
|
|
109
|
+
config.onComplete?.();
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
reset: () => {
|
|
113
|
+
set(initialState);
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
registerTarget: (targetId: string, measurements: TargetMeasurements) => {
|
|
117
|
+
set((state: GuidonStore) => ({
|
|
118
|
+
targetMeasurements: {
|
|
119
|
+
...state.targetMeasurements,
|
|
120
|
+
[targetId]: measurements,
|
|
121
|
+
},
|
|
122
|
+
}));
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
unregisterTarget: (targetId: string) => {
|
|
126
|
+
set((state: GuidonStore) => {
|
|
127
|
+
const { [targetId]: _, ...rest } = state.targetMeasurements;
|
|
128
|
+
return { targetMeasurements: rest };
|
|
129
|
+
});
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
setLoading: (isLoading: boolean) => {
|
|
133
|
+
set({ isLoading });
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
setError: (error: string | null) => {
|
|
137
|
+
set({ error });
|
|
138
|
+
},
|
|
139
|
+
}));
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Guidon API for external control
|
|
143
|
+
* Can be used outside of React components
|
|
144
|
+
*/
|
|
145
|
+
export const Guidon = {
|
|
146
|
+
/**
|
|
147
|
+
* Configure the walkthrough with steps and options
|
|
148
|
+
*/
|
|
149
|
+
configure: (config: GuidonConfig) => {
|
|
150
|
+
useGuidonStore.getState().configure(config);
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Start the walkthrough
|
|
155
|
+
*/
|
|
156
|
+
start: () => {
|
|
157
|
+
useGuidonStore.getState().start();
|
|
158
|
+
},
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Go to the next step
|
|
162
|
+
*/
|
|
163
|
+
next: () => {
|
|
164
|
+
useGuidonStore.getState().next();
|
|
165
|
+
},
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Go to the previous step
|
|
169
|
+
*/
|
|
170
|
+
previous: () => {
|
|
171
|
+
useGuidonStore.getState().previous();
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Go to a specific step by index
|
|
176
|
+
*/
|
|
177
|
+
goToStep: (index: number) => {
|
|
178
|
+
useGuidonStore.getState().goToStep(index);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Skip the walkthrough
|
|
183
|
+
*/
|
|
184
|
+
skip: () => {
|
|
185
|
+
useGuidonStore.getState().skip();
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Complete the walkthrough
|
|
190
|
+
*/
|
|
191
|
+
complete: () => {
|
|
192
|
+
useGuidonStore.getState().complete();
|
|
193
|
+
},
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Reset the walkthrough to initial state
|
|
197
|
+
*/
|
|
198
|
+
reset: () => {
|
|
199
|
+
useGuidonStore.getState().reset();
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Check if the walkthrough is currently active
|
|
204
|
+
*/
|
|
205
|
+
isActive: () => {
|
|
206
|
+
return useGuidonStore.getState().isActive;
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check if the walkthrough has been completed
|
|
211
|
+
*/
|
|
212
|
+
isCompleted: () => {
|
|
213
|
+
return useGuidonStore.getState().isCompleted;
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get the current step index
|
|
218
|
+
*/
|
|
219
|
+
getCurrentStepIndex: () => {
|
|
220
|
+
return useGuidonStore.getState().currentStepIndex;
|
|
221
|
+
},
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Get the current step
|
|
225
|
+
*/
|
|
226
|
+
getCurrentStep: () => {
|
|
227
|
+
const state = useGuidonStore.getState();
|
|
228
|
+
return state.config?.steps[state.currentStepIndex] ?? null;
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Get all steps
|
|
233
|
+
*/
|
|
234
|
+
getSteps: () => {
|
|
235
|
+
return useGuidonStore.getState().config?.steps ?? [];
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Subscribe to store changes
|
|
240
|
+
*/
|
|
241
|
+
subscribe: useGuidonStore.subscribe,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Hook selectors for common use cases
|
|
246
|
+
*/
|
|
247
|
+
export const useGuidonActive = () =>
|
|
248
|
+
useGuidonStore((state: GuidonStore) => state.isActive);
|
|
249
|
+
|
|
250
|
+
export const useGuidonStep = () =>
|
|
251
|
+
useGuidonStore((state: GuidonStore) => {
|
|
252
|
+
if (!state.config || !state.isActive) return null;
|
|
253
|
+
return state.config.steps[state.currentStepIndex];
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
export const useGuidonProgress = () =>
|
|
257
|
+
useGuidonStore((state: GuidonStore) => ({
|
|
258
|
+
currentStep: state.currentStepIndex + 1,
|
|
259
|
+
totalSteps: state.config?.steps.length ?? 0,
|
|
260
|
+
percentage: state.config
|
|
261
|
+
? ((state.currentStepIndex + 1) / state.config.steps.length) * 100
|
|
262
|
+
: 0,
|
|
263
|
+
}));
|
|
264
|
+
|
|
265
|
+
export const useTargetMeasurements = (targetId: string) =>
|
|
266
|
+
useGuidonStore(
|
|
267
|
+
(state: GuidonStore) => state.targetMeasurements[targetId],
|
|
268
|
+
);
|