@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.
@@ -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
+ }
@@ -0,0 +1,2 @@
1
+ export * from './adapters';
2
+ export * from './hooks';
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
+ );