feat(onboarding): integrate widget into dashboard and sidebar indicator
This commit is contained in:
parent
3797ff7925
commit
3ce559a24c
4 changed files with 427 additions and 2 deletions
189
app/composables/useOnboarding.js
Normal file
189
app/composables/useOnboarding.js
Normal file
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue