/** * 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(() => !!completedAt.value || (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: 'https://wiki.ghostguild.org', 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.slug}`, 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 (completedAt.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), } }