From 3ce559a24cf89b89f18dc9e7bbdbd9f53976b129 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Thu, 9 Apr 2026 22:46:39 +0100 Subject: [PATCH] feat(onboarding): integrate widget into dashboard and sidebar indicator --- app/components/AppNavigation.vue | 23 ++- app/components/OnboardingWidget.vue | 215 ++++++++++++++++++++++++++++ app/composables/useOnboarding.js | 189 ++++++++++++++++++++++++ app/pages/member/dashboard.vue | 2 + 4 files changed, 427 insertions(+), 2 deletions(-) create mode 100644 app/components/OnboardingWidget.vue create mode 100644 app/composables/useOnboarding.js diff --git a/app/components/AppNavigation.vue b/app/components/AppNavigation.vue index 69fd95c..8955897 100644 --- a/app/components/AppNavigation.vue +++ b/app/components/AppNavigation.vue @@ -17,8 +17,13 @@ :to="item.path" :class="{ active: isActive(item.path) }" @click="handleNavigate" - >{{ item.label }} + {{ item.label }} + +
  • + isAuthenticated.value && !memberData.value?.onboarding?.completedAt +); + const handleNavigate = () => { if (props.isMobile) { emit("navigate"); @@ -279,4 +288,14 @@ const exploreItems = [ .sidebar-meta a { color: var(--candle-dim); } + +.onboarding-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--candle); + margin-left: 6px; + vertical-align: middle; +} diff --git a/app/components/OnboardingWidget.vue b/app/components/OnboardingWidget.vue new file mode 100644 index 0000000..5d6c710 --- /dev/null +++ b/app/components/OnboardingWidget.vue @@ -0,0 +1,215 @@ + + + + + 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/app/pages/member/dashboard.vue b/app/pages/member/dashboard.vue index b56798f..a978c3b 100644 --- a/app/pages/member/dashboard.vue +++ b/app/pages/member/dashboard.vue @@ -26,6 +26,8 @@