- Timezone: curated USelectMenu dropdown (app/config/timezones.js), preserves unknown saved values
- Profile save now uses useToast() for success/error; remove inline save banner
- Nav onboarding dot nudged down 1px for optical alignment with lowercase text
- Onboarding: skip a suggestion with POST /api/onboarding/track {skip}; member.onboarding.skipped map; does not affect graduation
- CirclePicker takes :saved-value so 'Current' badge stays until save completes
- PrivacyToggle is binary (USwitch labeled Private); member schema enum reduced to ['members','private']; zod coerces legacy 'public'
- New /member/payment-setup page: HelcimPay $0 verify + update-contribution, wired from account.vue via requiresPaymentSetup redirect
- Helcim portal: NUXT_PUBLIC_HELCIM_PORTAL_URL env + account.vue 'Manage billing in Helcim' link
- Migration script: scripts/migrate-privacy-public-to-members.js
208 lines
5.8 KiB
JavaScript
208 lines
5.8 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,
|
|
hasEngagedBoard: false,
|
|
hasClickedWiki: false,
|
|
}))
|
|
|
|
const skipped = useState('onboarding.skipped', () => ({
|
|
profileTags: false,
|
|
visitEvent: false,
|
|
board: false,
|
|
wiki: false,
|
|
}))
|
|
|
|
const completedAt = useState('onboarding.completedAt', () => null)
|
|
const loading = useState('onboarding.loading', () => false)
|
|
const recommendations = useState('onboarding.recommendations', () => ({
|
|
events: [],
|
|
wiki: [],
|
|
}))
|
|
|
|
// Track whether we've already fetched status this session
|
|
const _fetched = useState('onboarding._fetched', () => false)
|
|
|
|
// For the purpose of advancing the suggestion widget, a skipped goal is
|
|
// treated as "done" — the underlying goal/graduation check is unchanged.
|
|
const effectiveGoals = computed(() => ({
|
|
hasProfileTags: goals.value.hasProfileTags || skipped.value.profileTags,
|
|
hasVisitedEvent: goals.value.hasVisitedEvent || skipped.value.visitEvent,
|
|
hasEngagedBoard: goals.value.hasEngagedBoard || skipped.value.board,
|
|
hasClickedWiki: goals.value.hasClickedWiki || skipped.value.wiki,
|
|
}))
|
|
|
|
const isComplete = computed(() =>
|
|
!!completedAt.value ||
|
|
(effectiveGoals.value.hasProfileTags &&
|
|
effectiveGoals.value.hasVisitedEvent &&
|
|
effectiveGoals.value.hasEngagedBoard &&
|
|
effectiveGoals.value.hasClickedWiki)
|
|
)
|
|
|
|
const pickCategory = options.pickCategory || ((categories) => {
|
|
return categories[Math.floor(Math.random() * categories.length)]
|
|
})
|
|
|
|
const currentSuggestion = computed(() => {
|
|
// Not graduated — return highest-priority incomplete, non-skipped goal
|
|
if (!isComplete.value) {
|
|
if (!effectiveGoals.value.hasProfileTags) {
|
|
return {
|
|
key: 'profileTags',
|
|
text: 'Complete your profile by adding your craft and community tags',
|
|
action: '/member/profile',
|
|
actionText: 'Set up tags',
|
|
}
|
|
}
|
|
if (!effectiveGoals.value.hasVisitedEvent) {
|
|
return {
|
|
key: 'visitEvent',
|
|
text: 'Check out upcoming events',
|
|
action: '/events',
|
|
actionText: 'Browse events',
|
|
}
|
|
}
|
|
if (!effectiveGoals.value.hasEngagedBoard) {
|
|
return {
|
|
key: 'board',
|
|
text: 'Explore the board to find collaborators',
|
|
action: '/board',
|
|
actionText: 'Explore board',
|
|
}
|
|
}
|
|
if (!effectiveGoals.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', '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])
|
|
}
|
|
|
|
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 === '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?.skipped) {
|
|
skipped.value = { ...skipped.value, ...data.skipped }
|
|
}
|
|
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, wiki] = await Promise.allSettled([
|
|
$fetch('/api/events/recommended'),
|
|
$fetch('/api/wiki/recommended'),
|
|
])
|
|
recommendations.value = {
|
|
events: events.status === 'fulfilled' ? (events.value || []) : [],
|
|
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
|
|
}
|
|
}
|
|
|
|
async function skipSuggestion(key) {
|
|
// Optimistically advance locally; server call is fire-and-forget.
|
|
if (skipped.value[key] !== undefined) {
|
|
skipped.value = { ...skipped.value, [key]: true }
|
|
}
|
|
try {
|
|
await $fetch('/api/onboarding/track', {
|
|
method: 'POST',
|
|
body: { skip: key },
|
|
})
|
|
} catch {
|
|
// Non-fatal — will re-fetch on next session
|
|
}
|
|
}
|
|
|
|
// Initialize on first use
|
|
fetchStatus()
|
|
|
|
return {
|
|
goals: readonly(goals),
|
|
isComplete: readonly(isComplete),
|
|
completedAt: readonly(completedAt),
|
|
currentSuggestion,
|
|
trackGoal,
|
|
skipSuggestion,
|
|
skipped: readonly(skipped),
|
|
recommendations: readonly(recommendations),
|
|
loading: readonly(loading),
|
|
}
|
|
}
|