feat(onboarding): integrate widget into dashboard and sidebar indicator

This commit is contained in:
Jennie Robinson Faber 2026-04-09 22:46:39 +01:00
parent 3797ff7925
commit 3ce559a24c
4 changed files with 427 additions and 2 deletions

View file

@ -17,8 +17,13 @@
:to="item.path" :to="item.path"
:class="{ active: isActive(item.path) }" :class="{ active: isActive(item.path) }"
@click="handleNavigate" @click="handleNavigate"
>{{ item.label }}</NuxtLink
> >
{{ item.label }}
<span
v-if="item.path === '/member/dashboard' && showOnboardingDot"
class="onboarding-dot"
/>
</NuxtLink>
</li> </li>
<li> <li>
<a href="#" class="sign-out" @click.prevent="handleLogout" <a href="#" class="sign-out" @click.prevent="handleLogout"
@ -128,9 +133,13 @@ const props = defineProps({
const emit = defineEmits(["navigate"]); const emit = defineEmits(["navigate"]);
const route = useRoute(); const route = useRoute();
const { isAuthenticated, logout } = useAuth(); const { isAuthenticated, memberData, logout } = useAuth();
const isDev = import.meta.dev; const isDev = import.meta.dev;
const showOnboardingDot = computed(() =>
isAuthenticated.value && !memberData.value?.onboarding?.completedAt
);
const handleNavigate = () => { const handleNavigate = () => {
if (props.isMobile) { if (props.isMobile) {
emit("navigate"); emit("navigate");
@ -279,4 +288,14 @@ const exploreItems = [
.sidebar-meta a { .sidebar-meta a {
color: var(--candle-dim); 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;
}
</style> </style>

View file

@ -0,0 +1,215 @@
<template>
<ClientOnly>
<div v-if="!loading" class="onboarding-widget dashed-box no-hover">
<!-- Welcome mode: onboarding in progress -->
<template v-if="!isComplete">
<div class="ow-header">
<h3 class="ow-title">Welcome to Ghost Guild</h3>
<p class="ow-intro">Get oriented here are a few things to explore as a new member.</p>
</div>
<div class="ow-suggestion">
<span class="ow-suggestion-text">{{ currentSuggestion.text }}</span>
<NuxtLink
v-if="currentSuggestion.action && !currentSuggestion.isExternal"
:to="currentSuggestion.action"
class="btn btn-primary ow-action"
>
{{ currentSuggestion.actionText }}
</NuxtLink>
<a
v-else-if="currentSuggestion.isExternal"
href="https://wiki.ghostguild.org"
target="_blank"
rel="noopener"
class="btn btn-primary ow-action"
@click="trackGoal('wikiClicked')"
>
{{ currentSuggestion.actionText }}
</a>
</div>
<div class="ow-progress">
<span class="ow-progress-label">{{ completedCount }} of 4</span>
<span class="ow-dots">
<span
v-for="i in 4"
:key="i"
class="ow-dot"
:class="{ 'ow-dot--done': i <= completedCount }"
/>
</span>
</div>
</template>
<!-- Suggestion mode: onboarding complete -->
<template v-else>
<!-- Empty state -->
<div v-if="currentSuggestion.key === 'empty'" class="ow-empty">
{{ currentSuggestion.text }}
</div>
<!-- Event recommendation -->
<div v-else-if="currentSuggestion.key === 'event'" class="ow-rec">
<div class="section-label">Suggested</div>
<span class="ow-rec-text">{{ currentSuggestion.text }}</span>
<NuxtLink
:to="currentSuggestion.action"
class="ow-rec-link"
>
{{ currentSuggestion.actionText }} &rarr;
</NuxtLink>
</div>
<!-- Ecology recommendation -->
<div v-else-if="currentSuggestion.key === 'ecology'" class="ow-rec">
<div class="section-label">Suggested</div>
<span class="ow-rec-text">{{ currentSuggestion.text }}</span>
<NuxtLink
:to="currentSuggestion.action"
class="ow-rec-link"
>
{{ currentSuggestion.actionText }} &rarr;
</NuxtLink>
</div>
<!-- Wiki recommendation -->
<div v-else-if="currentSuggestion.key === 'wiki'" class="ow-rec">
<div class="section-label">Suggested</div>
<span class="ow-rec-text">{{ currentSuggestion.text }}</span>
<a
v-if="currentSuggestion.action"
:href="currentSuggestion.action"
target="_blank"
rel="noopener"
class="ow-rec-link"
@click="trackGoal('wikiClicked')"
>
{{ currentSuggestion.actionText }} &rarr;
</a>
</div>
</template>
</div>
</ClientOnly>
</template>
<script setup>
const { goals, isComplete, currentSuggestion, trackGoal, loading } = useOnboarding()
const completedCount = computed(() => {
const g = goals.value
return [g.hasProfileTags, g.hasVisitedEvent, g.hasEngagedEcology, g.hasClickedWiki]
.filter(Boolean).length
})
</script>
<style scoped>
.onboarding-widget {
padding: 16px 20px;
}
/* Welcome mode */
.ow-header {
margin-bottom: 12px;
}
.ow-title {
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text-bright);
margin: 0 0 4px;
}
.ow-intro {
font-size: 12px;
color: var(--text-dim);
margin: 0;
line-height: 1.5;
}
.ow-suggestion {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-top: 1px dashed var(--border);
flex-wrap: wrap;
}
.ow-suggestion-text {
font-size: 12px;
color: var(--text);
line-height: 1.5;
}
.ow-action {
flex-shrink: 0;
font-size: 11px;
padding: 5px 14px;
}
.ow-progress {
display: flex;
align-items: center;
gap: 8px;
padding-top: 10px;
border-top: 1px dashed var(--border);
}
.ow-progress-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-faint);
}
.ow-dots {
display: flex;
gap: 4px;
}
.ow-dot {
width: 6px;
height: 6px;
border: 1px dashed var(--border);
display: inline-block;
}
.ow-dot--done {
background: var(--candle);
border-color: var(--candle);
border-style: solid;
}
/* Suggestion mode (graduated) */
.ow-rec {
display: flex;
flex-direction: column;
gap: 4px;
}
.ow-rec .section-label {
margin-bottom: 2px;
}
.ow-rec-text {
font-size: 12px;
color: var(--text);
line-height: 1.5;
}
.ow-rec-link {
font-size: 11px;
color: var(--candle);
margin-top: 2px;
display: inline-block;
}
.ow-empty {
font-size: 12px;
color: var(--text-faint);
line-height: 1.5;
}
</style>

View 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),
}
}

View file

@ -26,6 +26,8 @@
<!-- Dashboard Content --> <!-- Dashboard Content -->
<template v-else> <template v-else>
<OnboardingWidget />
<ColumnsLayout cols="events-sidebar" :limit="5"> <ColumnsLayout cols="events-sidebar" :limit="5">
<!-- Member Status Banner --> <!-- Member Status Banner -->
<MemberStatusBanner /> <MemberStatusBanner />