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
|
|
@ -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>
|
||||||
|
|
|
||||||
215
app/components/OnboardingWidget.vue
Normal file
215
app/components/OnboardingWidget.vue
Normal 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 }} →
|
||||||
|
</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 }} →
|
||||||
|
</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 }} →
|
||||||
|
</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>
|
||||||
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue