feat(member): account/profile polish + tier upgrade flow
- 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
This commit is contained in:
parent
08fc3884da
commit
7292b11c0b
18 changed files with 604 additions and 122 deletions
|
|
@ -10,6 +10,13 @@ export function useOnboarding(options = {}) {
|
|||
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', () => ({
|
||||
|
|
@ -20,12 +27,21 @@ export function useOnboarding(options = {}) {
|
|||
// 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 ||
|
||||
(goals.value.hasProfileTags &&
|
||||
goals.value.hasVisitedEvent &&
|
||||
goals.value.hasEngagedBoard &&
|
||||
goals.value.hasClickedWiki)
|
||||
(effectiveGoals.value.hasProfileTags &&
|
||||
effectiveGoals.value.hasVisitedEvent &&
|
||||
effectiveGoals.value.hasEngagedBoard &&
|
||||
effectiveGoals.value.hasClickedWiki)
|
||||
)
|
||||
|
||||
const pickCategory = options.pickCategory || ((categories) => {
|
||||
|
|
@ -33,9 +49,9 @@ export function useOnboarding(options = {}) {
|
|||
})
|
||||
|
||||
const currentSuggestion = computed(() => {
|
||||
// Not graduated — return highest-priority incomplete goal
|
||||
// Not graduated — return highest-priority incomplete, non-skipped goal
|
||||
if (!isComplete.value) {
|
||||
if (!goals.value.hasProfileTags) {
|
||||
if (!effectiveGoals.value.hasProfileTags) {
|
||||
return {
|
||||
key: 'profileTags',
|
||||
text: 'Complete your profile by adding your craft and community tags',
|
||||
|
|
@ -43,7 +59,7 @@ export function useOnboarding(options = {}) {
|
|||
actionText: 'Set up tags',
|
||||
}
|
||||
}
|
||||
if (!goals.value.hasVisitedEvent) {
|
||||
if (!effectiveGoals.value.hasVisitedEvent) {
|
||||
return {
|
||||
key: 'visitEvent',
|
||||
text: 'Check out upcoming events',
|
||||
|
|
@ -51,7 +67,7 @@ export function useOnboarding(options = {}) {
|
|||
actionText: 'Browse events',
|
||||
}
|
||||
}
|
||||
if (!goals.value.hasEngagedBoard) {
|
||||
if (!effectiveGoals.value.hasEngagedBoard) {
|
||||
return {
|
||||
key: 'board',
|
||||
text: 'Explore the board to find collaborators',
|
||||
|
|
@ -59,7 +75,7 @@ export function useOnboarding(options = {}) {
|
|||
actionText: 'Explore board',
|
||||
}
|
||||
}
|
||||
if (!goals.value.hasClickedWiki) {
|
||||
if (!effectiveGoals.value.hasClickedWiki) {
|
||||
return {
|
||||
key: 'wiki',
|
||||
text: 'Browse the wiki for resources and guides',
|
||||
|
|
@ -118,6 +134,9 @@ export function useOnboarding(options = {}) {
|
|||
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
|
||||
}
|
||||
|
|
@ -157,6 +176,21 @@ export function useOnboarding(options = {}) {
|
|||
}
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
|
|
@ -166,6 +200,8 @@ export function useOnboarding(options = {}) {
|
|||
completedAt: readonly(completedAt),
|
||||
currentSuggestion,
|
||||
trackGoal,
|
||||
skipSuggestion,
|
||||
skipped: readonly(skipped),
|
||||
recommendations: readonly(recommendations),
|
||||
loading: readonly(loading),
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue