From 7292b11c0b40853a12c5bdd02931f9a3edbfbf00 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Tue, 14 Apr 2026 20:35:37 +0100 Subject: [PATCH 001/203] 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 @@ diff --git a/app/composables/useBoardChannels.js b/app/composables/useBoardChannels.js index d499b77..03eb872 100644 --- a/app/composables/useBoardChannels.js +++ b/app/composables/useBoardChannels.js @@ -1,7 +1,3 @@ -/** - * Board Channels Composable - * Shared state + helpers for mapping board tags to Slack channels. - */ export function useBoardChannels() { const channels = useState('board.channels', () => []) @@ -11,15 +7,6 @@ export function useBoardChannels() { return channels.value } - function resolveTagChannel(tagSlugs = []) { - if (!tagSlugs?.length) return null - return ( - channels.value.find((channel) => - (channel.tagSlugs || []).some((slug) => tagSlugs.includes(slug)) - ) || null - ) - } - function slackUrl(channelId) { return `https://gammaspace.slack.com/archives/${channelId}` } @@ -27,7 +14,6 @@ export function useBoardChannels() { return { channels: readonly(channels), fetchChannels, - resolveTagChannel, slackUrl, } } diff --git a/app/composables/useBoardPosts.js b/app/composables/useBoardPosts.js index 3f0b0d9..987d6c8 100644 --- a/app/composables/useBoardPosts.js +++ b/app/composables/useBoardPosts.js @@ -1,7 +1,3 @@ -/** - * Board Posts Composable - * Shared state + CRUD for board posts. - */ export function useBoardPosts() { const posts = useState('board.posts', () => []) const loading = useState('board.loading', () => false) @@ -17,29 +13,29 @@ export function useBoardPosts() { } } - async function createPost(body, refreshParams = {}) { + async function createPost(body) { const created = await $fetch('/api/board/posts', { method: 'POST', body, }) - await fetchPosts(refreshParams) + await fetchPosts() return created } - async function updatePost(id, body, refreshParams = {}) { + async function updatePost(id, body) { const updated = await $fetch(`/api/board/posts/${id}`, { method: 'PATCH', body, }) - await fetchPosts(refreshParams) + await fetchPosts() return updated } - async function deletePost(id, refreshParams = {}) { + async function deletePost(id) { const result = await $fetch(`/api/board/posts/${id}`, { method: 'DELETE', }) - await fetchPosts(refreshParams) + await fetchPosts() return result } diff --git a/app/pages/board.vue b/app/pages/board.vue index 460c78b..6ee1af3 100644 --- a/app/pages/board.vue +++ b/app/pages/board.vue @@ -1,13 +1,11 @@ - - - + - -
- - -
- + + - +
+
+ + +
+
+ + +
+
+ +
+ + + />
@@ -142,14 +142,12 @@
@@ -221,11 +219,11 @@ const form = reactive({ circle: "community", motivation: "", contributionTier: "15", - agreedToTerms: false, + agreedToGuidelines: false, }); const isFormValid = computed(() => { - return form.name && form.circle && form.contributionTier && form.agreedToTerms; + return form.name && form.circle && form.contributionTier && form.agreedToGuidelines; }); const needsPayment = computed(() => { @@ -283,7 +281,7 @@ const handleAccept = async () => { circle: form.circle, motivation: form.motivation || undefined, contributionTier: form.contributionTier, - agreedToTerms: form.agreedToTerms, + agreedToGuidelines: form.agreedToGuidelines, token: token.value, }, }); diff --git a/app/pages/community-guidelines.vue b/app/pages/community-guidelines.vue new file mode 100644 index 0000000..2708198 --- /dev/null +++ b/app/pages/community-guidelines.vue @@ -0,0 +1,377 @@ + + + + + diff --git a/app/pages/join.vue b/app/pages/join.vue index c928df8..fc1ae21 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -65,7 +65,7 @@
  • Full access to the knowledge commons, Slack, and peer support
  • Free access to all Ghost Guild events
  • -
  • One member, one vote
  • +
  • Equal access for every member, regardless of contribution
  • Your circle reflects where you are, not rank
  • Pay what you can ($0–$50+/month, separate from circle)
  • Higher contributions create solidarity spots for others
  • @@ -251,6 +251,20 @@ }" />
+
+ +

- By joining you agree to our - community guidelines. You - can change your circle or contribution at any time from your + You can change your circle or contribution at any time from your dashboard. Payment is handled securely through Helcim { - return form.name && form.email && form.circle && form.contributionTier; + return ( + form.name && + form.email && + form.circle && + form.contributionTier && + form.agreedToGuidelines + ); }); // Check if payment is required @@ -471,6 +490,7 @@ const handleSubmit = async () => { email: form.email, circle: form.circle, contributionTier: form.contributionTier, + agreedToGuidelines: form.agreedToGuidelines, billingAddress: form.billingAddress, }, }); @@ -961,6 +981,25 @@ onUnmounted(() => { color: var(--candle-dim); } +/* ---- CHECKBOX ---- */ +.checkbox-label { + display: flex; + align-items: flex-start; + gap: 8px; + cursor: pointer; + font-size: 12px; + color: var(--text-dim); + line-height: 1.5; +} +.checkbox-label input { + margin-top: 3px; + flex-shrink: 0; +} +.checkbox-label a, +.checkbox-label :deep(a) { + color: var(--candle); +} + /* ---- ERROR & SUCCESS BOXES ---- */ .error-box { border: 1px dashed var(--ember); diff --git a/app/pages/policies/[slug].vue b/app/pages/policies/[slug].vue new file mode 100644 index 0000000..17e3ac9 --- /dev/null +++ b/app/pages/policies/[slug].vue @@ -0,0 +1,79 @@ + + + + + diff --git a/app/pages/policies/privacy.vue b/app/pages/policies/privacy.vue new file mode 100644 index 0000000..97934fa --- /dev/null +++ b/app/pages/policies/privacy.vue @@ -0,0 +1,328 @@ + + + + + diff --git a/app/pages/policies/terms.vue b/app/pages/policies/terms.vue new file mode 100644 index 0000000..e46a684 --- /dev/null +++ b/app/pages/policies/terms.vue @@ -0,0 +1,357 @@ + + + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index ed13830..b3190cf 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -36,6 +36,20 @@ export default defineNuxtConfig({ build: { transpile: ["vue-cal"], }, + routeRules: { + "/policies/code-of-conduct": { + redirect: { + to: "https://publish.obsidian.md/baby-ghosts-corp-docs/Public/Policies/Code+of+Conduct", + statusCode: 302, + }, + }, + "/policies/conflict-resolution": { + redirect: { + to: "https://publish.obsidian.md/baby-ghosts-corp-docs/Public/Policies/Conflict+Resolution+Policy", + statusCode: 302, + }, + }, + }, plausible: { domain: "ghostguild.org", }, diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js index 2120053..db22b2a 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -1,5 +1,4 @@ // Create a Helcim customer -import jwt from 'jsonwebtoken' import Member from '../../models/member.js' import { connectDB } from '../../utils/mongoose.js' import { createHelcimCustomer } from '../../utils/helcim.js' @@ -7,7 +6,6 @@ import { createHelcimCustomer } from '../../utils/helcim.js' export default defineEventHandler(async (event) => { try { await connectDB() - const config = useRuntimeConfig(event) const body = await validateBody(event, helcimCustomerSchema) // Check if member already exists @@ -33,34 +31,16 @@ export default defineEventHandler(async (event) => { circle: body.circle, contributionTier: body.contributionTier, helcimCustomerId: customerData.id, - status: 'pending_payment' + status: 'pending_payment', + agreement: { acceptedAt: new Date() } }) - // Generate JWT token for the session - const token = jwt.sign( - { - memberId: member._id, - email: body.email, - helcimCustomerId: customerData.id - }, - config.jwtSecret, - { expiresIn: '7d' } - ) + setAuthCookie(event, member) - // Set the session cookie server-side - setCookie(event, 'auth-token', token, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 7, // 7 days (matches verify.get.js and refresh.post.js) - path: '/', - domain: undefined // Let browser set domain automatically - }) return { success: true, customerId: customerData.id, customerCode: customerData.customerCode, - token, member: { id: member._id, email: member.email, diff --git a/server/api/invite/accept.post.js b/server/api/invite/accept.post.js index 278c1c3..6497709 100644 --- a/server/api/invite/accept.post.js +++ b/server/api/invite/accept.post.js @@ -46,6 +46,7 @@ export default defineEventHandler(async (event) => { contributionTier: body.contributionTier, bio: body.motivation || undefined, status: body.contributionTier === '0' ? 'active' : 'pending_payment', + agreement: { acceptedAt: new Date() }, }) await assignMemberNumber(member._id) diff --git a/server/models/member.js b/server/models/member.js index 1480eb8..f34abd6 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -83,6 +83,10 @@ const memberSchema = new mongoose.Schema({ inviteEmailSent: { type: Boolean, default: false }, inviteEmailSentAt: Date, + agreement: { + acceptedAt: Date, + }, + // Magic link single-use enforcement magicLinkJti: String, magicLinkJtiUsed: { type: Boolean, default: false }, diff --git a/server/utils/schemas.js b/server/utils/schemas.js index 24a8ff8..6e1abc4 100644 --- a/server/utils/schemas.js +++ b/server/utils/schemas.js @@ -57,7 +57,8 @@ export const helcimCustomerSchema = z.object({ name: z.string().min(1).max(200), email: z.string().trim().toLowerCase().email(), circle: z.enum(['community', 'founder', 'practitioner']).optional(), - contributionTier: z.enum(['0', '5', '15', '30', '50']).optional() + contributionTier: z.enum(['0', '5', '15', '30', '50']).optional(), + agreedToGuidelines: z.literal(true) }) export const helcimInitializePaymentSchema = z.object({ @@ -147,7 +148,7 @@ export const seriesTicketPurchaseSchema = z.object({ name: z.string().min(1).max(200), email: z.string().trim().toLowerCase().email(), paymentId: z.string().max(500).optional(), - ticketType: z.enum(['member', 'public', 'guest']).optional(), + ticketType: z.enum(['member', 'public', 'guest']), }) export const seriesTicketEligibilitySchema = z.object({ @@ -345,7 +346,7 @@ export const inviteAcceptSchema = z.object({ circle: z.enum(['community', 'founder', 'practitioner']), motivation: z.string().max(5000).optional(), contributionTier: z.enum(['0', '5', '15', '30', '50']), - agreedToTerms: z.literal(true), + agreedToGuidelines: z.literal(true), token: z.string().min(1) }) diff --git a/tests/server/api/validation-phase3.test.js b/tests/server/api/validation-phase3.test.js index a707fee..fd63e78 100644 --- a/tests/server/api/validation-phase3.test.js +++ b/tests/server/api/validation-phase3.test.js @@ -80,7 +80,8 @@ describe('helcimCustomerSchema', () => { it('accepts valid customer data', () => { const result = helcimCustomerSchema.safeParse({ name: 'Jane Doe', - email: 'jane@example.com' + email: 'jane@example.com', + agreedToGuidelines: true }) expect(result.success).toBe(true) }) @@ -88,7 +89,8 @@ describe('helcimCustomerSchema', () => { it('lowercases email', () => { const result = helcimCustomerSchema.safeParse({ name: 'Jane', - email: 'JANE@Example.COM' + email: 'JANE@Example.COM', + agreedToGuidelines: true }) expect(result.success).toBe(true) expect(result.data.email).toBe('jane@example.com') @@ -97,7 +99,8 @@ describe('helcimCustomerSchema', () => { it('rejects invalid email', () => { const result = helcimCustomerSchema.safeParse({ name: 'Jane', - email: 'not-an-email' + email: 'not-an-email', + agreedToGuidelines: true }) expect(result.success).toBe(false) }) @@ -106,11 +109,29 @@ describe('helcimCustomerSchema', () => { const result = helcimCustomerSchema.safeParse({ name: 'Jane', email: 'jane@example.com', + agreedToGuidelines: true, role: 'admin' }) expect(result.success).toBe(true) expect(result.data).not.toHaveProperty('role') }) + + it('rejects missing agreedToGuidelines', () => { + const result = helcimCustomerSchema.safeParse({ + name: 'Jane', + email: 'jane@example.com' + }) + expect(result.success).toBe(false) + }) + + it('rejects agreedToGuidelines:false', () => { + const result = helcimCustomerSchema.safeParse({ + name: 'Jane', + email: 'jane@example.com', + agreedToGuidelines: false + }) + expect(result.success).toBe(false) + }) }) describe('helcimSubscriptionSchema', () => { @@ -297,14 +318,16 @@ describe('seriesTicketPurchaseSchema', () => { it('accepts valid series ticket purchase', () => { const result = seriesTicketPurchaseSchema.safeParse({ name: 'Buyer', - email: 'buyer@example.com' + email: 'buyer@example.com', + ticketType: 'member' }) expect(result.success).toBe(true) }) it('rejects missing name', () => { const result = seriesTicketPurchaseSchema.safeParse({ - email: 'buyer@example.com' + email: 'buyer@example.com', + ticketType: 'member' }) expect(result.success).toBe(false) }) diff --git a/tests/server/api/validation.test.js b/tests/server/api/validation.test.js index 9433a5c..c8d45ba 100644 --- a/tests/server/api/validation.test.js +++ b/tests/server/api/validation.test.js @@ -6,7 +6,8 @@ import { memberProfileUpdateSchema, eventRegistrationSchema, paymentVerifySchema, - adminEventCreateSchema + adminEventCreateSchema, + seriesTicketPurchaseSchema } from '../../../server/utils/schemas.js' import { validateBody } from '../../../server/utils/validateBody.js' @@ -183,6 +184,42 @@ describe('memberProfileUpdateSchema', () => { }) }) +describe('seriesTicketPurchaseSchema', () => { + const validBody = { + name: 'Test User', + email: 'test@example.com', + ticketType: 'member' + } + + it('accepts member, public, and guest ticket types', () => { + for (const ticketType of ['member', 'public', 'guest']) { + const result = seriesTicketPurchaseSchema.safeParse({ ...validBody, ticketType }) + expect(result.success).toBe(true) + } + }) + + it('rejects a body missing ticketType (closes pricing-mismatch gap)', () => { + const { ticketType, ...rest } = validBody + const result = seriesTicketPurchaseSchema.safeParse(rest) + expect(result.success).toBe(false) + }) + + it('rejects null ticketType', () => { + const result = seriesTicketPurchaseSchema.safeParse({ ...validBody, ticketType: null }) + expect(result.success).toBe(false) + }) + + it('rejects an unknown ticketType value', () => { + const result = seriesTicketPurchaseSchema.safeParse({ ...validBody, ticketType: 'vip' }) + expect(result.success).toBe(false) + }) + + it('rejects empty-string ticketType', () => { + const result = seriesTicketPurchaseSchema.safeParse({ ...validBody, ticketType: '' }) + expect(result.success).toBe(false) + }) +}) + // --- validateBody integration tests --- describe('validateBody', () => { From 15329e3e84d63b9aebdee908be29fde7023f648a Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sat, 18 Apr 2026 17:06:17 +0100 Subject: [PATCH 018/203] refactor(events): gate member benefits on hasMemberAccess Extracts hasMemberAccess(member) in tickets.js and uses it across event registration, ticket purchase, and series purchase flows so guest, suspended, and cancelled records no longer count as members while pending_payment still does. --- app/components/SeriesPassPurchase.vue | 5 +- server/api/events/[id]/register.post.js | 12 +- .../api/events/[id]/tickets/purchase.post.js | 14 +- .../api/series/[id]/tickets/purchase.post.js | 12 +- server/utils/tickets.js | 35 +++-- tests/server/api/event-registration.test.js | 8 +- tests/server/utils/tickets.test.js | 132 ++++++++++++++++++ 7 files changed, 188 insertions(+), 30 deletions(-) diff --git a/app/components/SeriesPassPurchase.vue b/app/components/SeriesPassPurchase.vue index eda86f7..8d3e7f1 100644 --- a/app/components/SeriesPassPurchase.vue +++ b/app/components/SeriesPassPurchase.vue @@ -4,7 +4,7 @@

+ />

Loading series pass information...

@@ -58,7 +58,7 @@ }} -
+
$ @@ -240,6 +240,16 @@

{{ guidanceLabel }}

+
+
+

+ You'll be charged ${{ firstCharge }} today (${{ form.contributionAmount }}/month × 12). +

+

+ Then ${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}, until you cancel. +

+
+
@@ -503,23 +441,18 @@ const needsPayment = computed(() => { const guidanceLabel = computed(() => getGuidanceLabel(form.contributionAmount)); -const flowStepLabel = computed(() => { - switch (flowState.value) { - case "creating-customer": - case "opening-payment": - return "Step 2 of 3 — Payment"; - case "processing-payment": - case "creating-subscription": - return "Step 2 of 3 — Finalizing"; - case "success": - return "Step 3 of 3 — Welcome"; - case "error": - return "Something went wrong"; - default: - return ""; - } +const firstCharge = computed(() => { + const amount = form.contributionAmount || 0; + return cadence.value === "annual" ? amount * 12 : amount; }); +const flowSummary = computed(() => ({ + name: form.name, + email: form.email, + circle: form.circle, + contribution: formatContributionAmount(form.contributionAmount), +})); + const handleSubmit = async () => { if (isSubmitting.value || !isFormValid.value) return; @@ -918,6 +851,26 @@ onUnmounted(() => { color: var(--ink-soft, currentColor); } +/* ---- BILLING SUMMARY ---- */ +.billing-summary { + padding: 12px 16px; + border: 1px dashed var(--border); + background: var(--surface); +} +.billing-summary-line { + font-size: 13px; + color: var(--text); + line-height: 1.5; + margin: 0; +} +.billing-summary-line + .billing-summary-line { + margin-top: 4px; +} +.billing-summary-line strong { + color: var(--text-bright); + font-weight: 600; +} + /* ---- CIRCLE RADIOS ---- */ .circle-radios { display: grid; @@ -1073,26 +1026,6 @@ onUnmounted(() => { max-width: 600px; } -/* ---- DETAILS LIST (confirmation) ---- */ -.details-list { - display: flex; - flex-direction: column; - gap: 8px; -} -.details-row { - display: flex; - justify-content: space-between; - align-items: baseline; - font-size: 13px; -} -.details-row dt { - color: var(--text-faint); -} -.details-row dd { - color: var(--text-bright); - font-weight: 500; -} - /* ---- PAYMENT INSTRUCTION ---- */ .payment-instruction { font-size: 13px; @@ -1183,48 +1116,4 @@ onUnmounted(() => { } } -.join-flow-overlay { - position: fixed; - inset: 0; - z-index: 50; - background: rgba(42, 32, 21, 0.72); /* --parch @ 72% */ - backdrop-filter: blur(4px); - display: flex; - align-items: center; - justify-content: center; - padding: 24px; -} - -.join-flow-card { - background: var(--bg); - border: 1px dashed var(--border); - padding: 32px; - max-width: 520px; - width: 100%; - max-height: calc(100vh - 48px); - overflow-y: auto; -} - -.join-flow-step { - font-family: var(--font-body); - font-size: 0.75rem; - letter-spacing: 0.08em; - text-transform: uppercase; - color: var(--text-dim); - margin-bottom: 12px; -} - -.join-flow-heading { - font-family: var(--font-display); - font-size: 1.5rem; - color: var(--text-bright); - margin: 0 0 16px; -} - -.join-flow-body { - font-family: var(--font-body); - color: var(--text); - line-height: 1.5; - margin: 0; -} diff --git a/app/pages/member/account.vue b/app/pages/member/account.vue index cf0c3ed..5bccb66 100644 --- a/app/pages/member/account.vue +++ b/app/pages/member/account.vue @@ -72,9 +72,9 @@ - + @@ -200,11 +200,11 @@

Cancelling closes your account and ends access to member-only - spaces, including Slack. If you're cancelling because of a + spaces, including Slack.

@@ -242,7 +242,7 @@

$ @@ -269,8 +269,8 @@

{{ guidanceLabel }}

-
- Changes take effect on your next billing cycle +
+ {{ contributionChangeHint }}