From 5d3b04af48e992369f2bb836d2c3c90ffb80aa84 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:41:30 +0100 Subject: [PATCH] feat(onboarding): add useOnboarding composable --- app/composables/useOnboarding.js | 189 +++++++++ .../client/composables/useOnboarding.test.js | 390 ++++++++++++++++++ 2 files changed, 579 insertions(+) create mode 100644 app/composables/useOnboarding.js create mode 100644 tests/client/composables/useOnboarding.test.js diff --git a/app/composables/useOnboarding.js b/app/composables/useOnboarding.js new file mode 100644 index 0000000..6dfcbf7 --- /dev/null +++ b/app/composables/useOnboarding.js @@ -0,0 +1,189 @@ +/** + * Onboarding Composable + * Tracks new member onboarding goals and provides post-graduation suggestions. + */ +export function useOnboarding(options = {}) { + const goals = useState('onboarding.goals', () => ({ + hasProfileTags: false, + hasVisitedEvent: false, + hasEngagedEcology: false, + hasClickedWiki: false, + })) + + const completedAt = useState('onboarding.completedAt', () => null) + const loading = useState('onboarding.loading', () => false) + const recommendations = useState('onboarding.recommendations', () => ({ + events: [], + ecology: [], + wiki: [], + })) + + // Track whether we've already fetched status this session + const _fetched = useState('onboarding._fetched', () => false) + + const isComplete = computed(() => + goals.value.hasProfileTags && + goals.value.hasVisitedEvent && + goals.value.hasEngagedEcology && + goals.value.hasClickedWiki + ) + + const pickCategory = options.pickCategory || ((categories) => { + return categories[Math.floor(Math.random() * categories.length)] + }) + + const currentSuggestion = computed(() => { + // Not graduated — return highest-priority incomplete goal + if (!isComplete.value) { + if (!goals.value.hasProfileTags) { + return { + key: 'profileTags', + text: 'Complete your profile by adding your craft and community tags', + action: '/member/profile', + actionText: 'Set up tags', + } + } + if (!goals.value.hasVisitedEvent) { + return { + key: 'visitEvent', + text: 'Check out upcoming events', + action: '/events', + actionText: 'Browse events', + } + } + if (!goals.value.hasEngagedEcology) { + return { + key: 'ecology', + text: 'Explore the community ecology to find collaborators', + action: '/ecology', + actionText: 'Explore ecology', + } + } + if (!goals.value.hasClickedWiki) { + return { + key: 'wiki', + text: 'Browse the wiki for resources and guides', + action: null, + actionText: 'Browse wiki', + isExternal: true, + } + } + } + + // Graduated — suggestion mode + const cats = ['events', 'ecology', 'wiki'].filter( + (c) => recommendations.value[c]?.length > 0 + ) + + if (cats.length === 0) { + return { key: 'empty', text: 'No suggestions right now' } + } + + const selected = pickCategory(cats) + const items = recommendations.value[selected] + + if (items?.length > 0) { + return buildRecommendation(selected, items[0]) + } + + // Fallback to first non-empty category (shouldn't happen since we filtered) + for (const cat of cats) { + if (recommendations.value[cat]?.length > 0) { + return buildRecommendation(cat, recommendations.value[cat][0]) + } + } + + return { key: 'empty', text: 'No suggestions right now' } + }) + + function buildRecommendation(category, item) { + if (category === 'events') { + return { + key: 'event', + text: `Upcoming event: ${item.title}`, + action: `/events/${item._id}`, + actionText: 'View event', + } + } + if (category === 'ecology') { + return { + key: 'ecology', + text: `Connect with ${item.name || 'a member'} in the ecology`, + action: '/ecology', + actionText: 'Explore ecology', + } + } + if (category === 'wiki') { + return { + key: 'wiki', + text: `Recommended: ${item.title}`, + action: item.url || null, + actionText: 'Read article', + isExternal: true, + } + } + return { key: 'empty', text: 'No suggestions right now' } + } + + async function fetchStatus() { + if (_fetched.value) return + loading.value = true + try { + const data = await $fetch('/api/onboarding/status') + if (data?.goals) { + goals.value = { ...goals.value, ...data.goals } + } + if (data?.completedAt) { + completedAt.value = data.completedAt + } + _fetched.value = true + + // If graduated, fetch recommendations + if (isComplete.value) { + await fetchRecommendations() + } + } catch { + // Silently fail — goals stay at defaults + } finally { + loading.value = false + } + } + + async function fetchRecommendations() { + const [events, ecology, wiki] = await Promise.allSettled([ + $fetch('/api/events/recommended'), + $fetch('/api/ecology/suggestions'), + $fetch('/api/wiki/recommended'), + ]) + recommendations.value = { + events: events.status === 'fulfilled' ? (events.value || []) : [], + ecology: ecology.status === 'fulfilled' ? (ecology.value?.suggestions || []) : [], + wiki: wiki.status === 'fulfilled' ? (wiki.value || []) : [], + } + } + + async function trackGoal(goalName) { + if (isComplete.value) return + try { + await $fetch('/api/onboarding/track', { + method: 'POST', + body: { goal: goalName }, + }) + } catch { + // Fire-and-forget + } + } + + // Initialize on first use + fetchStatus() + + return { + goals: readonly(goals), + isComplete: readonly(isComplete), + completedAt: readonly(completedAt), + currentSuggestion, + trackGoal, + recommendations: readonly(recommendations), + loading: readonly(loading), + } +} diff --git a/tests/client/composables/useOnboarding.test.js b/tests/client/composables/useOnboarding.test.js new file mode 100644 index 0000000..322b2c1 --- /dev/null +++ b/tests/client/composables/useOnboarding.test.js @@ -0,0 +1,390 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { ref, computed, readonly } from 'vue' + +// --- Nuxt auto-import stubs --- + +vi.stubGlobal('computed', computed) +vi.stubGlobal('readonly', readonly) + +// useState: return a fresh ref per key, reset between tests +const stateStore = {} +vi.stubGlobal('useState', (key, init) => { + if (!stateStore[key]) { + stateStore[key] = ref(init ? init() : null) + } + return stateStore[key] +}) + +function resetState() { + for (const key of Object.keys(stateStore)) { + delete stateStore[key] + } +} + +// $fetch mock +const fetchMock = vi.fn() +vi.stubGlobal('$fetch', fetchMock) + +// --- Tests --- + +describe('useOnboarding', () => { + let useOnboarding + + beforeEach(async () => { + resetState() + fetchMock.mockReset() + // Default: status endpoint returns all-false goals + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: false, + hasVisitedEvent: false, + hasEngagedEcology: false, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + // Re-import to get clean module state + vi.resetModules() + const mod = await import('../../../app/composables/useOnboarding.js') + useOnboarding = mod.useOnboarding + }) + + // 9.1: Initializes goals from API response + it('9.1: initializes goals from API response', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: false, + hasEngagedEcology: true, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + const { goals, loading } = useOnboarding() + + // Wait for fetchStatus to complete + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(goals.value.hasProfileTags).toBe(true) + expect(goals.value.hasVisitedEvent).toBe(false) + expect(goals.value.hasEngagedEcology).toBe(true) + expect(goals.value.hasClickedWiki).toBe(false) + }) + + // 9.2: isComplete true when all goals true + it('9.2: isComplete is true when all goals are true', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: true, + }, + completedAt: '2026-04-01T00:00:00Z', + }) + } + if (url === '/api/events/recommended') return Promise.resolve([]) + if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] }) + if (url === '/api/wiki/recommended') return Promise.resolve([]) + return Promise.resolve(null) + }) + + const { isComplete, completedAt, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(isComplete.value).toBe(true) + expect(completedAt.value).toBe('2026-04-01T00:00:00Z') + }) + + // 9.3: isComplete false with partial goals + it('9.3: isComplete is false with partial goals', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: false, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + const { isComplete, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(isComplete.value).toBe(false) + }) + + // 9.4: currentSuggestion returns profile tags when no tags set (priority 1) + it('9.4: currentSuggestion returns profile tags when no tags set', async () => { + const { currentSuggestion, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('profileTags') + expect(currentSuggestion.value.action).toBe('/member/profile') + expect(currentSuggestion.value.actionText).toBe('Set up tags') + }) + + // 9.5: currentSuggestion returns event when tags set but no event visit (priority 2) + it('9.5: currentSuggestion returns event when tags set but no event visit', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: false, + hasEngagedEcology: false, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + const { currentSuggestion, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('visitEvent') + expect(currentSuggestion.value.action).toBe('/events') + expect(currentSuggestion.value.actionText).toBe('Browse events') + }) + + // 9.6: currentSuggestion returns ecology when tags + event done (priority 3) + it('9.6: currentSuggestion returns ecology when tags + event done', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: false, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + const { currentSuggestion, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('ecology') + expect(currentSuggestion.value.action).toBe('/ecology') + }) + + // 9.7: currentSuggestion returns wiki when only wiki remaining (priority 4) + it('9.7: currentSuggestion returns wiki when only wiki remaining', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: false, + }, + completedAt: null, + }) + } + return Promise.resolve(null) + }) + + const { currentSuggestion, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('wiki') + expect(currentSuggestion.value.action).toBeNull() + expect(currentSuggestion.value.isExternal).toBe(true) + }) + + // 9.8: trackGoal fires POST and is skipped when complete + it('9.8: trackGoal fires POST and is skipped when complete', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: true, + }, + completedAt: '2026-04-01T00:00:00Z', + }) + } + if (url === '/api/events/recommended') return Promise.resolve([]) + if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] }) + if (url === '/api/wiki/recommended') return Promise.resolve([]) + return Promise.resolve(null) + }) + + const { trackGoal, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + await trackGoal('eventPageVisited') + + // Should NOT have called the track endpoint since isComplete is true + const trackCalls = fetchMock.mock.calls.filter( + (c) => c[0] === '/api/onboarding/track' + ) + expect(trackCalls).toHaveLength(0) + }) + + // 9.9: Suggestion mode uses injectable pickCategory + it('9.9: suggestion mode uses injectable pickCategory', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: true, + }, + completedAt: '2026-04-01T00:00:00Z', + }) + } + if (url === '/api/events/recommended') { + return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }]) + } + if (url === '/api/ecology/suggestions') { + return Promise.resolve({ suggestions: [{ name: 'Alex' }] }) + } + if (url === '/api/wiki/recommended') { + return Promise.resolve([{ title: 'Co-op Guide', url: 'https://wiki.example.com/coop' }]) + } + return Promise.resolve(null) + }) + + // Always pick 'wiki' + const { currentSuggestion, loading } = useOnboarding({ + pickCategory: () => 'wiki', + }) + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('wiki') + expect(currentSuggestion.value.text).toContain('Co-op Guide') + expect(currentSuggestion.value.isExternal).toBe(true) + }) + + // 9.10: Suggestion mode falls back when selected category empty + it('9.10: suggestion mode falls back when selected category empty', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: true, + }, + completedAt: '2026-04-01T00:00:00Z', + }) + } + if (url === '/api/events/recommended') { + return Promise.resolve([{ _id: 'e1', title: 'Game Jam' }]) + } + if (url === '/api/ecology/suggestions') { + return Promise.resolve({ suggestions: [] }) + } + if (url === '/api/wiki/recommended') { + return Promise.resolve([]) + } + return Promise.resolve(null) + }) + + // pickCategory only gets non-empty categories, so it should only see 'events' + const pickedCategories = [] + const { currentSuggestion, loading } = useOnboarding({ + pickCategory: (cats) => { + pickedCategories.push([...cats]) + return cats[0] + }, + }) + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + // Access computed first to trigger pickCategory + expect(currentSuggestion.value.key).toBe('event') + expect(currentSuggestion.value.text).toContain('Game Jam') + // Only events had results, so pickCategory should only have received ['events'] + expect(pickedCategories[0]).toEqual(['events']) + }) + + // 9.11: Suggestion mode shows fallback when all categories empty + it('9.11: suggestion mode shows fallback when all categories empty', async () => { + fetchMock.mockImplementation((url) => { + if (url === '/api/onboarding/status') { + return Promise.resolve({ + goals: { + hasProfileTags: true, + hasVisitedEvent: true, + hasEngagedEcology: true, + hasClickedWiki: true, + }, + completedAt: '2026-04-01T00:00:00Z', + }) + } + if (url === '/api/events/recommended') return Promise.resolve([]) + if (url === '/api/ecology/suggestions') return Promise.resolve({ suggestions: [] }) + if (url === '/api/wiki/recommended') return Promise.resolve([]) + return Promise.resolve(null) + }) + + const { currentSuggestion, loading } = useOnboarding() + + await vi.waitFor(() => { + expect(loading.value).toBe(false) + }) + + expect(currentSuggestion.value.key).toBe('empty') + expect(currentSuggestion.value.text).toBe('No suggestions right now') + }) +})