From 7292b11c0b40853a12c5bdd02931f9a3edbfbf00 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 14 Apr 2026 20:35:37 +0100 Subject: [PATCH] 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 --- app/components/AppNavigation.vue | 1 + app/components/CirclePicker.vue | 12 +- app/components/OnboardingWidget.vue | 31 ++- app/components/PrivacyToggle.vue | 72 +++---- app/composables/useOnboarding.js | 54 ++++- app/config/timezones.js | 39 ++++ app/pages/member/account.vue | 48 ++++- app/pages/member/payment-setup.vue | 189 ++++++++++++++++++ app/pages/member/profile.vue | 67 +++---- nuxt.config.ts | 1 + server/api/onboarding/status.get.js | 8 + server/api/onboarding/track.post.js | 10 +- server/models/member.js | 24 ++- server/utils/schemas.js | 12 +- .../client/composables/useOnboarding.test.js | 80 ++++++++ tests/server/api/onboarding-status.test.js | 31 +++ tests/server/api/onboarding-track.test.js | 39 ++++ tests/server/api/validation.test.js | 8 +- 18 files changed, 604 insertions(+), 122 deletions(-) create mode 100644 app/config/timezones.js create mode 100644 app/pages/member/payment-setup.vue diff --git a/app/components/AppNavigation.vue b/app/components/AppNavigation.vue index 1add2ef..e1c9c80 100644 --- a/app/components/AppNavigation.vue +++ b/app/components/AppNavigation.vue @@ -339,5 +339,6 @@ const exploreItems = [ background: var(--candle); margin-left: 6px; vertical-align: middle; + transform: translateY(-1px); } diff --git a/app/components/CirclePicker.vue b/app/components/CirclePicker.vue index 227bb3c..6a8e1e4 100644 --- a/app/components/CirclePicker.vue +++ b/app/components/CirclePicker.vue @@ -4,13 +4,16 @@ v-for="circle in circles" :key="circle.value" class="circle-option" - :class="{ current: modelValue === circle.value }" + :class="{ + selected: modelValue === circle.value, + current: savedValue === circle.value, + }" @click="$emit('update:modelValue', circle.value)" > {{ circle.label }} {{ circle.description }} Current @@ -21,6 +24,7 @@ diff --git a/app/composables/useOnboarding.js b/app/composables/useOnboarding.js index e457f28..4ccb345 100644 --- a/app/composables/useOnboarding.js +++ b/app/composables/useOnboarding.js @@ -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), } diff --git a/app/config/timezones.js b/app/config/timezones.js new file mode 100644 index 0000000..fa35923 --- /dev/null +++ b/app/config/timezones.js @@ -0,0 +1,39 @@ +// Curated IANA timezone options for the profile editor. +// Grouped roughly by region; values are standard IANA identifiers. +export const TIMEZONE_OPTIONS = [ + // Americas + { label: 'Pacific — Los Angeles', value: 'America/Los_Angeles' }, + { label: 'Pacific — Vancouver', value: 'America/Vancouver' }, + { label: 'Mountain — Denver', value: 'America/Denver' }, + { label: 'Mountain — Edmonton', value: 'America/Edmonton' }, + { label: 'Central — Chicago', value: 'America/Chicago' }, + { label: 'Central — Mexico City', value: 'America/Mexico_City' }, + { label: 'Eastern — Toronto', value: 'America/Toronto' }, + { label: 'Eastern — New York', value: 'America/New_York' }, + { label: 'Atlantic — Halifax', value: 'America/Halifax' }, + { label: 'Newfoundland — St. John’s', value: 'America/St_Johns' }, + { label: 'Brazil — São Paulo', value: 'America/Sao_Paulo' }, + { label: 'Argentina — Buenos Aires', value: 'America/Argentina/Buenos_Aires' }, + + // Europe / Africa + { label: 'UTC', value: 'UTC' }, + { label: 'UK — London', value: 'Europe/London' }, + { label: 'Ireland — Dublin', value: 'Europe/Dublin' }, + { label: 'Central Europe — Berlin', value: 'Europe/Berlin' }, + { label: 'Central Europe — Paris', value: 'Europe/Paris' }, + { label: 'Central Europe — Madrid', value: 'Europe/Madrid' }, + { label: 'Eastern Europe — Helsinki', value: 'Europe/Helsinki' }, + { label: 'Africa — Lagos', value: 'Africa/Lagos' }, + { label: 'Africa — Johannesburg', value: 'Africa/Johannesburg' }, + + // Asia / Oceania + { label: 'Middle East — Dubai', value: 'Asia/Dubai' }, + { label: 'India — Kolkata', value: 'Asia/Kolkata' }, + { label: 'Southeast Asia — Bangkok', value: 'Asia/Bangkok' }, + { label: 'China — Shanghai', value: 'Asia/Shanghai' }, + { label: 'Japan — Tokyo', value: 'Asia/Tokyo' }, + { label: 'Korea — Seoul', value: 'Asia/Seoul' }, + { label: 'Australia — Sydney', value: 'Australia/Sydney' }, + { label: 'Australia — Perth', value: 'Australia/Perth' }, + { label: 'New Zealand — Auckland', value: 'Pacific/Auckland' }, +]; diff --git a/app/pages/member/account.vue b/app/pages/member/account.vue index 44a8f30..c69f561 100644 --- a/app/pages/member/account.vue +++ b/app/pages/member/account.vue @@ -68,6 +68,15 @@ }} + + Manage billing in Helcim → + @@ -178,6 +187,7 @@ - Profile updated. - {{ - saveError - }} @@ -264,6 +262,7 @@