ghostguild-org/app/composables/useOnboarding.js
Jennie Robinson Faber e791a0d480
Some checks failed
Test / vitest (push) Successful in 10m44s
Test / playwright (push) Failing after 9m15s
Test / visual (push) Failing after 9m7s
Test / Notify on failure (push) Successful in 2s
fix(onboarding): fix widget links, isComplete logic, and event slugs
Use dynamic href for external links, check completedAt for graduation,
link events by slug instead of _id, and remove stale click handler.
2026-04-09 23:52:04 +01:00

190 lines
5.2 KiB
JavaScript

/**
* 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),
}
}