From a88aa621985514a9e82ed41752c7c41da7960ef1 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 27 Aug 2025 20:40:54 +0100 Subject: [PATCH 1/4] Add series management and ticketing features: Introduce series event functionality in event creation, enhance event display with series information, and implement ticketing options for public events. Update layouts and improve form handling for better user experience. --- app/components/NaturalDateInput.vue | 238 +++++++++ app/layouts/admin.vue | 14 +- app/pages/admin/events/create.vue | 493 ++++++++++++++++- app/pages/admin/events/index.vue | 20 +- app/pages/admin/series-management.vue | 504 ++++++++++++++++++ app/pages/admin/series/create.vue | 268 ++++++++++ app/pages/events/index.vue | 155 +++++- app/pages/series/index.vue | 234 ++++++++ package-lock.json | 19 + package.json | 1 + scripts/seed-series-events.js | 187 +++++++ server/api/admin/events.post.js | 23 +- server/api/admin/events/[id].put.js | 17 + server/api/admin/series.get.js | 60 +++ server/api/admin/series.post.js | 49 ++ server/api/admin/series/[id].delete.js | 58 ++ server/api/admin/series/[id].put.js | 62 +++ server/api/events/[id]/guest-register.post.js | 112 ++++ server/api/events/[id]/payment.post.js | 136 +++++ server/api/events/[id]/register.post.js | 44 +- server/api/series/[id].get.js | 78 +++ server/api/series/index.get.js | 91 ++++ server/models/event.js | 43 ++ server/models/series.js | 35 ++ 24 files changed, 2897 insertions(+), 44 deletions(-) create mode 100644 app/components/NaturalDateInput.vue create mode 100644 app/pages/admin/series-management.vue create mode 100644 app/pages/admin/series/create.vue create mode 100644 app/pages/series/index.vue create mode 100644 scripts/seed-series-events.js create mode 100644 server/api/admin/series.get.js create mode 100644 server/api/admin/series.post.js create mode 100644 server/api/admin/series/[id].delete.js create mode 100644 server/api/admin/series/[id].put.js create mode 100644 server/api/events/[id]/guest-register.post.js create mode 100644 server/api/events/[id]/payment.post.js create mode 100644 server/api/series/[id].get.js create mode 100644 server/api/series/index.get.js create mode 100644 server/models/series.js diff --git a/app/components/NaturalDateInput.vue b/app/components/NaturalDateInput.vue new file mode 100644 index 0000000..c0f8983 --- /dev/null +++ b/app/components/NaturalDateInput.vue @@ -0,0 +1,238 @@ + + + \ No newline at end of file diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index a9194f7..fda4eef 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -57,18 +57,18 @@ - + - Analytics + Series @@ -159,15 +159,15 @@ - Analytics + Series diff --git a/app/pages/admin/events/create.vue b/app/pages/admin/events/create.vue index 299517a..81db3e0 100644 --- a/app/pages/admin/events/create.vue +++ b/app/pages/admin/events/create.vue @@ -139,12 +139,11 @@ -

{{ fieldErrors.startDate }}

@@ -153,12 +152,11 @@ -

{{ fieldErrors.endDate }}

@@ -177,11 +175,9 @@
-

When should registration close? (optional)

@@ -236,6 +232,273 @@ + +
+

Ticketing

+ +
+ + +
+ + +
+
+
+ + +
+ +
+ + +

Set to 0 for free public events

+
+
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ +
+

Price increases to regular price after this date

+
+
+
+ +
+

+ Note: Members always get free access to all events regardless of ticket settings. +

+
+
+
+ + +
+

Series Management

+ +
+ + +
+
+ +
+ + + New Series + +
+

+ Select an existing series or create a new one +

+
+ +
+
+
+ + +

+ {{ selectedSeriesId ? 'From selected series' : 'Unique identifier to group related events (use lowercase with dashes)' }} +

+
+ +
+ + +

Order within the series (1, 2, 3, etc.)

+
+
+ +
+ + +

{{ selectedSeriesId ? 'From selected series' : 'Descriptive name for the entire series' }}

+
+ +
+ + +

{{ selectedSeriesId ? 'From selected series' : 'Describe what the series covers and its goals' }}

+
+ +
+
+ + +
+ +
+ + +

{{ selectedSeriesId ? 'From selected series' : 'How many events will be in this series?' }}

+
+
+ +
+

+ Note: This event will be added to the existing "{{ eventForm.series.title }}" series. +

+
+
+
+
+
+

Event Settings

@@ -354,6 +617,10 @@ const editingEvent = ref(null) const showSuccessMessage = ref(false) const formErrors = ref([]) const fieldErrors = ref({}) +const seriesExists = ref(false) +const existingSeries = ref(null) +const selectedSeriesId = ref('') +const availableSeries = ref([]) const eventForm = reactive({ title: '', @@ -371,9 +638,63 @@ const eventForm = reactive({ targetCircles: [], maxAttendees: '', registrationRequired: false, - registrationDeadline: '' + registrationDeadline: '', + tickets: { + enabled: false, + public: { + available: false, + name: 'Public Ticket', + description: '', + price: 0, + quantity: null, + earlyBirdPrice: null, + earlyBirdDeadline: '' + } + }, + series: { + isSeriesEvent: false, + id: '', + title: '', + description: '', + type: 'workshop_series', + position: 1, + totalEvents: null + } }) +// Load available series +onMounted(async () => { + try { + const response = await $fetch('/api/admin/series') + availableSeries.value = response + } catch (error) { + console.error('Failed to load series:', error) + } +}) + +// Handle series selection +const onSeriesSelect = () => { + if (selectedSeriesId.value) { + const series = availableSeries.value.find(s => s.id === selectedSeriesId.value) + if (series) { + eventForm.series.id = series.id + eventForm.series.title = series.title + eventForm.series.description = series.description + eventForm.series.type = series.type + eventForm.series.totalEvents = series.totalEvents + eventForm.series.position = (series.eventCount || 0) + 1 + } + } else { + // Reset series form when no series is selected + eventForm.series.id = '' + eventForm.series.title = '' + eventForm.series.description = '' + eventForm.series.type = 'workshop_series' + eventForm.series.position = 1 + eventForm.series.totalEvents = null + } +} + // Check if we're editing an event if (route.query.edit) { try { @@ -398,8 +719,33 @@ if (route.query.edit) { targetCircles: event.targetCircles || [], maxAttendees: event.maxAttendees || '', registrationRequired: event.registrationRequired, - registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : '' + registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : '', + tickets: event.tickets || { + enabled: false, + public: { + available: false, + name: 'Public Ticket', + description: '', + price: 0, + quantity: null, + earlyBirdPrice: null, + earlyBirdDeadline: '' + } + }, + series: event.series || { + isSeriesEvent: false, + id: '', + title: '', + description: '', + type: 'workshop_series', + position: 1, + totalEvents: null + } }) + // Handle early bird deadline formatting + if (event.tickets?.public?.earlyBirdDeadline) { + eventForm.tickets.public.earlyBirdDeadline = new Date(event.tickets.public.earlyBirdDeadline).toISOString().slice(0, 16) + } } } catch (error) { console.error('Failed to load event for editing:', error) @@ -420,6 +766,20 @@ if (route.query.duplicate && process.client) { } } +// Check if we're creating a series event +if (route.query.series && process.client) { + const seriesData = sessionStorage.getItem('seriesEventData') + if (seriesData) { + try { + const data = JSON.parse(seriesData) + Object.assign(eventForm, data) + sessionStorage.removeItem('seriesEventData') + } catch (error) { + console.error('Failed to load series event data:', error) + } + } +} + const validateForm = () => { formErrors.value = [] fieldErrors.value = {} @@ -491,6 +851,63 @@ const validateForm = () => { return formErrors.value.length === 0 } +// Check if a series with this ID already exists +const checkExistingSeries = async () => { + if (!eventForm.series.id || selectedSeriesId.value) { + seriesExists.value = false + existingSeries.value = null + return + } + + try { + // First check in standalone series + const standaloneResponse = await $fetch(`/api/admin/series`) + const existingStandalone = standaloneResponse.find(s => s.id === eventForm.series.id) + + if (existingStandalone) { + seriesExists.value = true + existingSeries.value = existingStandalone + // Auto-fill series details + if (!eventForm.series.title || eventForm.series.title === '') { + eventForm.series.title = existingStandalone.title + } + if (!eventForm.series.description || eventForm.series.description === '') { + eventForm.series.description = existingStandalone.description + } + if (!eventForm.series.type || eventForm.series.type === 'workshop_series') { + eventForm.series.type = existingStandalone.type + } + if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) { + eventForm.series.totalEvents = existingStandalone.totalEvents + } + return + } + + // Fallback to legacy series check (events with series data) + const legacyResponse = await $fetch(`/api/series/${eventForm.series.id}`) + if (legacyResponse) { + seriesExists.value = true + existingSeries.value = legacyResponse + if (!eventForm.series.title || eventForm.series.title === '') { + eventForm.series.title = legacyResponse.title + } + if (!eventForm.series.description || eventForm.series.description === '') { + eventForm.series.description = legacyResponse.description + } + if (!eventForm.series.type || eventForm.series.type === 'workshop_series') { + eventForm.series.type = legacyResponse.type + } + if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) { + eventForm.series.totalEvents = legacyResponse.totalEvents + } + } + } catch (error) { + // Series doesn't exist yet + seriesExists.value = false + existingSeries.value = null + } +} + const saveEvent = async (redirect = true) => { if (!validateForm()) { // Scroll to top to show errors @@ -500,6 +917,27 @@ const saveEvent = async (redirect = true) => { creating.value = true try { + // If this is a series event and not using an existing series, create the standalone series first + if (eventForm.series.isSeriesEvent && eventForm.series.id && !selectedSeriesId.value) { + try { + await $fetch('/api/admin/series', { + method: 'POST', + body: { + id: eventForm.series.id, + title: eventForm.series.title, + description: eventForm.series.description, + type: eventForm.series.type, + totalEvents: eventForm.series.totalEvents + } + }) + } catch (seriesError) { + // Series might already exist, that's ok + if (!seriesError.data?.statusMessage?.includes('already exists')) { + throw seriesError + } + } + } + if (editingEvent.value) { await $fetch(`/api/admin/events/${editingEvent.value._id}`, { method: 'PUT', @@ -552,7 +990,28 @@ const saveAndCreateAnother = async () => { targetCircles: [], maxAttendees: '', registrationRequired: false, - registrationDeadline: '' + registrationDeadline: '', + tickets: { + enabled: false, + public: { + available: false, + name: 'Public Ticket', + description: '', + price: 0, + quantity: null, + earlyBirdPrice: null, + earlyBirdDeadline: '' + } + }, + series: { + isSeriesEvent: false, + id: '', + title: '', + description: '', + type: 'workshop_series', + position: 1, + totalEvents: null + } }) // Clear any existing errors diff --git a/app/pages/admin/events/index.vue b/app/pages/admin/events/index.vue index de6cf39..8be0c2a 100644 --- a/app/pages/admin/events/index.vue +++ b/app/pages/admin/events/index.vue @@ -27,6 +27,11 @@ +
@@ -77,6 +82,14 @@
{{ event.title }}
{{ event.description.substring(0, 100) }}...
+
+
+
+ {{ event.series.position }} +
+ {{ event.series.title }} +
+
@@ -193,6 +206,7 @@ const { data: events, pending, error, refresh } = await useFetch("/api/admin/eve const searchQuery = ref('') const typeFilter = ref('') const statusFilter = ref('') +const seriesFilter = ref('') const filteredEvents = computed(() => { if (!events.value) return [] @@ -207,7 +221,11 @@ const filteredEvents = computed(() => { const eventStatus = getEventStatus(event) const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value - return matchesSearch && matchesType && matchesStatus + const matchesSeries = !seriesFilter.value || + (seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) || + (seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent) + + return matchesSearch && matchesType && matchesStatus && matchesSeries }) }) diff --git a/app/pages/admin/series-management.vue b/app/pages/admin/series-management.vue new file mode 100644 index 0000000..4bf8198 --- /dev/null +++ b/app/pages/admin/series-management.vue @@ -0,0 +1,504 @@ + + + \ No newline at end of file diff --git a/app/pages/admin/series/create.vue b/app/pages/admin/series/create.vue new file mode 100644 index 0000000..9799d8f --- /dev/null +++ b/app/pages/admin/series/create.vue @@ -0,0 +1,268 @@ + + + \ No newline at end of file diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue index 5c23b52..7029d4a 100644 --- a/app/pages/events/index.vue +++ b/app/pages/events/index.vue @@ -66,6 +66,84 @@ + +
+ +
+

+ Active Event Series +

+

+ Multi-part workshops and recurring events designed to deepen your knowledge and build community connections. +

+
+ +
+
+
+
+ {{ formatSeriesType(series.type) }} +
+
+ + {{ series.eventCount }} events +
+
+ +

+ {{ series.title }} +

+ +

+ {{ series.description }} +

+ +
+
+
+
+ {{ event.series?.position || '?' }} +
+ {{ event.title }} +
+ + {{ formatEventDate(event.startDate) }} + +
+
+ +{{ series.events.length - 3 }} more events +
+
+ +
+
+ {{ formatDateRange(series.startDate, series.endDate) }} +
+ + {{ series.status }} + +
+
+
+
+
+
@@ -93,6 +171,17 @@
+ +
+
+
+ {{ event.series.position }} +
+ + {{ event.series.title }} +
+
+
+ +
+

+ Event Series +

+

+ Discover our multi-event series designed to take you on a journey of learning and growth +

+
+
+
+ + +
+ +
+
+

Loading series...

+
+ +
+
+ +
+
+
+
+
+ {{ formatSeriesType(series.type) }} +
+ + {{ series.status }} + +
+

{{ series.title }}

+

{{ series.description }}

+
+
+
{{ series.eventCount }}
+
Events
+
+ of {{ series.totalEvents }} planned +
+
+
+
+ + +
+
+
+
+
+ {{ event.series?.position || '?' }} +
+
+

{{ event.title }}

+
+
+ + {{ formatEventDate(event.startDate) }} +
+
+ + {{ formatEventTime(event.startDate) }} +
+
+ + {{ event.registrations.length }} registered +
+
+
+
+
+ + {{ getEventStatus(event) }} + + + View Event + +
+
+
+
+ + +
+
+
+ + Series runs from {{ formatDateRange(series.startDate, series.endDate) }} +
+
+ + {{ series.totalRegistrations }} total registrations +
+
+
+
+
+ +
+ +

No Event Series Available

+

+ We're currently planning exciting event series. Check back soon for multi-event learning journeys! +

+
+
+
+
+ + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c350064..4be3954 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@nuxt/ui": "^3.3.2", "@nuxtjs/plausible": "^1.2.0", "bcryptjs": "^3.0.2", + "chrono-node": "^2.8.4", "cloudinary": "^2.7.0", "eslint": "^9.34.0", "jsonwebtoken": "^9.0.2", @@ -6967,6 +6968,18 @@ "node": ">=18" } }, + "node_modules/chrono-node": { + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/chrono-node/-/chrono-node-2.8.4.tgz", + "integrity": "sha512-F+Rq88qF3H2dwjnFrl3TZrn5v4ZO57XxeQ+AhuL1C685So1hdUV/hT/q8Ja5UbmPYEZfx8VrxFDa72Dgldcxpg==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.10.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, "node_modules/ci-info": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.0.tgz", @@ -7666,6 +7679,12 @@ "node": ">= 12" } }, + "node_modules/dayjs": { + "version": "1.11.14", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.14.tgz", + "integrity": "sha512-E8fIdSxUlyqSA8XYGnNa3IkIzxtEmFjI+JU/6ic0P1zmSqyL6HyG5jHnpPjRguDNiaHLpfvHKWFiohNsJLqcJQ==", + "license": "MIT" + }, "node_modules/db0": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/db0/-/db0-0.3.2.tgz", diff --git a/package.json b/package.json index 241bacf..289240f 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@nuxt/ui": "^3.3.2", "@nuxtjs/plausible": "^1.2.0", "bcryptjs": "^3.0.2", + "chrono-node": "^2.8.4", "cloudinary": "^2.7.0", "eslint": "^9.34.0", "jsonwebtoken": "^9.0.2", diff --git a/scripts/seed-series-events.js b/scripts/seed-series-events.js new file mode 100644 index 0000000..1a2d765 --- /dev/null +++ b/scripts/seed-series-events.js @@ -0,0 +1,187 @@ +import { connectDB } from '../server/utils/mongoose.js' +import Event from '../server/models/event.js' + +async function seedSeriesEvents() { + try { + await connectDB() + console.log('Connected to database') + + // Workshop Series: "Cooperative Game Development Fundamentals" + const workshopSeries = [ + { + title: 'Cooperative Business Models in Game Development', + slug: 'coop-business-models-workshop', + tagline: 'Learn the foundations of cooperative business structures', + description: 'An introductory workshop covering the basic principles and structures of worker cooperatives in the game development industry.', + content: 'This workshop will cover the legal structures, governance models, and financial frameworks that make cooperative game studios successful.', + startDate: new Date('2024-10-15T19:00:00.000Z'), + endDate: new Date('2024-10-15T21:00:00.000Z'), + eventType: 'workshop', + location: '#workshop-fundamentals', + isOnline: true, + membersOnly: false, + registrationRequired: true, + maxAttendees: 50, + series: { + id: 'coop-dev-fundamentals', + title: 'Cooperative Game Development Fundamentals', + description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios', + type: 'workshop_series', + position: 1, + totalEvents: 4, + isSeriesEvent: true + }, + createdBy: 'admin' + }, + { + title: 'Democratic Decision Making in Creative Projects', + slug: 'democratic-decision-making-workshop', + tagline: 'Practical tools for collaborative project management', + description: 'Learn how to implement democratic decision-making processes that work for creative teams and game development projects.', + content: 'This workshop focuses on consensus building, conflict resolution, and collaborative project management techniques.', + startDate: new Date('2024-10-22T19:00:00.000Z'), + endDate: new Date('2024-10-22T21:00:00.000Z'), + eventType: 'workshop', + location: '#workshop-fundamentals', + isOnline: true, + membersOnly: false, + registrationRequired: true, + maxAttendees: 50, + series: { + id: 'coop-dev-fundamentals', + title: 'Cooperative Game Development Fundamentals', + description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios', + type: 'workshop_series', + position: 2, + totalEvents: 4, + isSeriesEvent: true + }, + createdBy: 'admin' + }, + { + title: 'Funding and Financial Models for Co-ops', + slug: 'coop-funding-workshop', + tagline: 'Sustainable financing for cooperative studios', + description: 'Explore funding options, revenue sharing models, and financial management strategies specific to cooperative game studios.', + content: 'This workshop covers grant opportunities, crowdfunding strategies, and internal financial management for worker cooperatives.', + startDate: new Date('2024-10-29T19:00:00.000Z'), + endDate: new Date('2024-10-29T21:00:00.000Z'), + eventType: 'workshop', + location: '#workshop-fundamentals', + isOnline: true, + membersOnly: false, + registrationRequired: true, + maxAttendees: 50, + series: { + id: 'coop-dev-fundamentals', + title: 'Cooperative Game Development Fundamentals', + description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios', + type: 'workshop_series', + position: 3, + totalEvents: 4, + isSeriesEvent: true + }, + createdBy: 'admin' + }, + { + title: 'Building Your Cooperative Studio', + slug: 'building-coop-studio-workshop', + tagline: 'From concept to reality: launching your co-op', + description: 'A practical guide to forming a cooperative game studio, covering legal formation, member recruitment, and launch strategies.', + content: 'This final workshop in the series provides a step-by-step guide to actually forming and launching a cooperative game studio.', + startDate: new Date('2024-11-05T19:00:00.000Z'), + endDate: new Date('2024-11-05T21:00:00.000Z'), + eventType: 'workshop', + location: '#workshop-fundamentals', + isOnline: true, + membersOnly: false, + registrationRequired: true, + maxAttendees: 50, + series: { + id: 'coop-dev-fundamentals', + title: 'Cooperative Game Development Fundamentals', + description: 'A comprehensive workshop series covering the essentials of building and running cooperative game studios', + type: 'workshop_series', + position: 4, + totalEvents: 4, + isSeriesEvent: true + }, + createdBy: 'admin' + } + ] + + // Monthly Community Meetup Series + const meetupSeries = [ + { + title: 'October Community Meetup', + slug: 'october-community-meetup', + tagline: 'Monthly gathering for cooperative game developers', + description: 'Join fellow cooperative game developers for informal networking, project sharing, and community building.', + content: 'Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.', + startDate: new Date('2024-10-12T18:00:00.000Z'), + endDate: new Date('2024-10-12T20:00:00.000Z'), + eventType: 'community', + location: '#community-meetup', + isOnline: true, + membersOnly: false, + registrationRequired: false, + series: { + id: 'monthly-meetups', + title: 'Monthly Community Meetups', + description: 'Regular monthly gatherings for the cooperative game development community', + type: 'recurring_meetup', + position: 1, + totalEvents: 12, + isSeriesEvent: true + }, + createdBy: 'admin' + }, + { + title: 'November Community Meetup', + slug: 'november-community-meetup', + tagline: 'Monthly gathering for cooperative game developers', + description: 'Join fellow cooperative game developers for informal networking, project sharing, and community building.', + content: 'Our monthly community meetup provides a relaxed environment to share your projects, get feedback, and connect with other developers interested in cooperative models.', + startDate: new Date('2024-11-09T18:00:00.000Z'), + endDate: new Date('2024-11-09T20:00:00.000Z'), + eventType: 'community', + location: '#community-meetup', + isOnline: true, + membersOnly: false, + registrationRequired: false, + series: { + id: 'monthly-meetups', + title: 'Monthly Community Meetups', + description: 'Regular monthly gatherings for the cooperative game development community', + type: 'recurring_meetup', + position: 2, + totalEvents: 12, + isSeriesEvent: true + }, + createdBy: 'admin' + } + ] + + // Insert all series events + const allSeriesEvents = [...workshopSeries, ...meetupSeries] + + for (const eventData of allSeriesEvents) { + const existingEvent = await Event.findOne({ slug: eventData.slug }) + if (!existingEvent) { + const event = new Event(eventData) + await event.save() + console.log(`Created series event: ${event.title}`) + } else { + console.log(`Series event already exists: ${eventData.title}`) + } + } + + console.log('Series events seeding completed!') + process.exit(0) + } catch (error) { + console.error('Error seeding series events:', error) + process.exit(1) + } +} + +seedSeriesEvents() \ No newline at end of file diff --git a/server/api/admin/events.post.js b/server/api/admin/events.post.js index 5e34869..4877eb3 100644 --- a/server/api/admin/events.post.js +++ b/server/api/admin/events.post.js @@ -29,13 +29,32 @@ export default defineEventHandler(async (event) => { await connectDB() - const newEvent = new Event({ + const eventData = { ...body, createdBy: 'admin@ghostguild.org', // TODO: Use actual authenticated user startDate: new Date(body.startDate), endDate: new Date(body.endDate), registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null - }) + } + + // Handle ticket data + if (body.tickets) { + eventData.tickets = { + enabled: body.tickets.enabled || false, + public: { + available: body.tickets.public?.available || false, + name: body.tickets.public?.name || 'Public Ticket', + description: body.tickets.public?.description || '', + price: body.tickets.public?.price || 0, + quantity: body.tickets.public?.quantity || null, + sold: 0, // Initialize sold count + earlyBirdPrice: body.tickets.public?.earlyBirdPrice || null, + earlyBirdDeadline: body.tickets.public?.earlyBirdDeadline ? new Date(body.tickets.public.earlyBirdDeadline) : null + } + } + } + + const newEvent = new Event(eventData) const savedEvent = await newEvent.save() diff --git a/server/api/admin/events/[id].put.js b/server/api/admin/events/[id].put.js index 2873689..50ae589 100644 --- a/server/api/admin/events/[id].put.js +++ b/server/api/admin/events/[id].put.js @@ -37,6 +37,23 @@ export default defineEventHandler(async (event) => { registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null, updatedAt: new Date() } + + // Handle ticket data + if (body.tickets) { + updateData.tickets = { + enabled: body.tickets.enabled || false, + public: { + available: body.tickets.public?.available || false, + name: body.tickets.public?.name || 'Public Ticket', + description: body.tickets.public?.description || '', + price: body.tickets.public?.price || 0, + quantity: body.tickets.public?.quantity || null, + sold: body.tickets.public?.sold || 0, + earlyBirdPrice: body.tickets.public?.earlyBirdPrice || null, + earlyBirdDeadline: body.tickets.public?.earlyBirdDeadline ? new Date(body.tickets.public.earlyBirdDeadline) : null + } + } + } const updatedEvent = await Event.findByIdAndUpdate( eventId, diff --git a/server/api/admin/series.get.js b/server/api/admin/series.get.js new file mode 100644 index 0000000..df3a84d --- /dev/null +++ b/server/api/admin/series.get.js @@ -0,0 +1,60 @@ +import Series from '../../models/series.js' +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + // Fetch all series + const series = await Series.find({ isActive: true }) + .sort({ createdAt: -1 }) + .lean() + + // For each series, get event count and statistics + const seriesWithStats = await Promise.all( + series.map(async (s) => { + const events = await Event.find({ + 'series.id': s.id, + 'series.isSeriesEvent': true + }).select('_id startDate endDate registrations').lean() + + const now = new Date() + const eventCount = events.length + const completedEvents = events.filter(e => e.endDate < now).length + const upcomingEvents = events.filter(e => e.startDate > now).length + + const firstEventDate = events.length > 0 ? + Math.min(...events.map(e => new Date(e.startDate))) : null + const lastEventDate = events.length > 0 ? + Math.max(...events.map(e => new Date(e.endDate))) : null + + let status = 'upcoming' + if (lastEventDate && lastEventDate < now) { + status = 'completed' + } else if (firstEventDate && firstEventDate <= now && lastEventDate && lastEventDate >= now) { + status = 'active' + } + + return { + ...s, + eventCount, + completedEvents, + upcomingEvents, + startDate: firstEventDate, + endDate: lastEventDate, + status, + totalRegistrations: events.reduce((sum, e) => sum + (e.registrations?.length || 0), 0) + } + }) + ) + + return seriesWithStats + } catch (error) { + console.error('Error fetching series:', error) + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch series' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/series.post.js b/server/api/admin/series.post.js new file mode 100644 index 0000000..2041e23 --- /dev/null +++ b/server/api/admin/series.post.js @@ -0,0 +1,49 @@ +import Series from '../../models/series.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + const body = await readBody(event) + + // Validate required fields + if (!body.id || !body.title || !body.description) { + throw createError({ + statusCode: 400, + statusMessage: 'Series ID, title, and description are required' + }) + } + + // Create new series + const newSeries = new Series({ + id: body.id, + title: body.title, + description: body.description, + type: body.type || 'workshop_series', + totalEvents: body.totalEvents || null, + createdBy: 'admin' // TODO: Get from authentication + }) + + await newSeries.save() + + return { + success: true, + data: newSeries + } + } catch (error) { + console.error('Error creating series:', error) + + if (error.code === 11000) { + throw createError({ + statusCode: 400, + statusMessage: 'A series with this ID already exists' + }) + } + + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to create series' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/series/[id].delete.js b/server/api/admin/series/[id].delete.js new file mode 100644 index 0000000..cc80202 --- /dev/null +++ b/server/api/admin/series/[id].delete.js @@ -0,0 +1,58 @@ +import Series from '../../../models/series.js' +import Event from '../../../models/event.js' +import { connectDB } from '../../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + const id = getRouterParam(event, 'id') + + if (!id) { + throw createError({ + statusCode: 400, + statusMessage: 'Series ID is required' + }) + } + + // Find the series + const series = await Series.findOne({ id: id }) + + if (!series) { + throw createError({ + statusCode: 404, + statusMessage: 'Series not found' + }) + } + + // Remove series relationship from all related events + await Event.updateMany( + { 'series.id': id, 'series.isSeriesEvent': true }, + { + $set: { + 'series.isSeriesEvent': false, + 'series.id': '', + 'series.title': '', + 'series.description': '', + 'series.type': 'workshop_series', + 'series.position': 1, + 'series.totalEvents': null + } + } + ) + + // Delete the series + await Series.deleteOne({ id: id }) + + return { + success: true, + message: 'Series deleted and events converted to standalone events' + } + } catch (error) { + console.error('Error deleting series:', error) + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to delete series' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/series/[id].put.js b/server/api/admin/series/[id].put.js new file mode 100644 index 0000000..0143938 --- /dev/null +++ b/server/api/admin/series/[id].put.js @@ -0,0 +1,62 @@ +import Series from '../../../models/series.js' +import Event from '../../../models/event.js' +import { connectDB } from '../../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + const id = getRouterParam(event, 'id') + const body = await readBody(event) + + if (!id) { + throw createError({ + statusCode: 400, + statusMessage: 'Series ID is required' + }) + } + + // Find and update the series + const series = await Series.findOne({ id: id }) + + if (!series) { + throw createError({ + statusCode: 404, + statusMessage: 'Series not found' + }) + } + + // Update series fields + if (body.title !== undefined) series.title = body.title + if (body.description !== undefined) series.description = body.description + if (body.type !== undefined) series.type = body.type + if (body.totalEvents !== undefined) series.totalEvents = body.totalEvents + if (body.isActive !== undefined) series.isActive = body.isActive + + await series.save() + + // Also update all related events with the new series information + await Event.updateMany( + { 'series.id': id, 'series.isSeriesEvent': true }, + { + $set: { + 'series.title': series.title, + 'series.description': series.description, + 'series.type': series.type, + 'series.totalEvents': series.totalEvents + } + } + ) + + return { + success: true, + data: series + } + } catch (error) { + console.error('Error updating series:', error) + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to update series' + }) + } +}) \ No newline at end of file diff --git a/server/api/events/[id]/guest-register.post.js b/server/api/events/[id]/guest-register.post.js new file mode 100644 index 0000000..3d31737 --- /dev/null +++ b/server/api/events/[id]/guest-register.post.js @@ -0,0 +1,112 @@ +import Event from '../../../models/event.js' +import { connectDB } from '../../../utils/mongoose.js' +import mongoose from 'mongoose' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + const identifier = getRouterParam(event, 'id') + const body = await readBody(event) + + if (!identifier) { + throw createError({ + statusCode: 400, + statusMessage: 'Event identifier is required' + }) + } + + // Validate required fields for guest registration + if (!body.name || !body.email) { + throw createError({ + statusCode: 400, + statusMessage: 'Name and email are required' + }) + } + + // Fetch the event + let eventData + if (mongoose.Types.ObjectId.isValid(identifier)) { + eventData = await Event.findById(identifier) + } + if (!eventData) { + eventData = await Event.findOne({ slug: identifier }) + } + + if (!eventData) { + throw createError({ + statusCode: 404, + statusMessage: 'Event not found' + }) + } + + // Check if event allows public registration (not members-only) + if (eventData.membersOnly) { + throw createError({ + statusCode: 403, + statusMessage: 'This event is for members only. Please become a member to register.' + }) + } + + // If event requires payment, reject guest registration + if (eventData.pricing.paymentRequired && !eventData.pricing.isFree) { + throw createError({ + statusCode: 402, + statusMessage: 'This event requires payment. Please use the payment registration endpoint.' + }) + } + + // Check if event is full + if (eventData.maxAttendees && eventData.registrations.length >= eventData.maxAttendees) { + throw createError({ + statusCode: 400, + statusMessage: 'Event is full' + }) + } + + // Check if already registered + const alreadyRegistered = eventData.registrations.some( + reg => reg.email.toLowerCase() === body.email.toLowerCase() + ) + + if (alreadyRegistered) { + throw createError({ + statusCode: 400, + statusMessage: 'You are already registered for this event' + }) + } + + // Add guest registration + eventData.registrations.push({ + name: body.name, + email: body.email.toLowerCase(), + membershipLevel: 'guest', + isMember: false, + paymentStatus: 'not_required', + amountPaid: 0, + registeredAt: new Date() + }) + + await eventData.save() + + // TODO: Send confirmation email for guest registration + + return { + success: true, + message: 'Successfully registered as guest', + registrationId: eventData.registrations[eventData.registrations.length - 1]._id, + note: 'As a guest, you have access to this free public event. Consider becoming a member for access to all events!' + } + + } catch (error) { + console.error('Error with guest registration:', error) + + if (error.statusCode) { + throw error + } + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to register as guest' + }) + } +}) \ No newline at end of file diff --git a/server/api/events/[id]/payment.post.js b/server/api/events/[id]/payment.post.js new file mode 100644 index 0000000..6d3c634 --- /dev/null +++ b/server/api/events/[id]/payment.post.js @@ -0,0 +1,136 @@ +import Event from '../../../models/event.js' +import Member from '../../../models/member.js' +import { connectDB } from '../../../utils/mongoose.js' +import { processHelcimPayment } from '../../../utils/helcim.js' +import mongoose from 'mongoose' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + const identifier = getRouterParam(event, 'id') + const body = await readBody(event) + + if (!identifier) { + throw createError({ + statusCode: 400, + statusMessage: 'Event identifier is required' + }) + } + + // Validate required payment fields + if (!body.name || !body.email || !body.paymentToken) { + throw createError({ + statusCode: 400, + statusMessage: 'Name, email, and payment token are required' + }) + } + + // Fetch the event + let eventData + if (mongoose.Types.ObjectId.isValid(identifier)) { + eventData = await Event.findById(identifier) + } + if (!eventData) { + eventData = await Event.findOne({ slug: identifier }) + } + + if (!eventData) { + throw createError({ + statusCode: 404, + statusMessage: 'Event not found' + }) + } + + // Check if event requires payment + if (eventData.pricing.isFree || !eventData.pricing.paymentRequired) { + throw createError({ + statusCode: 400, + statusMessage: 'This event does not require payment' + }) + } + + // Check if user is already registered + const existingRegistration = eventData.registrations.find( + reg => reg.email.toLowerCase() === body.email.toLowerCase() + ) + + if (existingRegistration) { + throw createError({ + statusCode: 400, + statusMessage: 'You are already registered for this event' + }) + } + + // Check if user is a member (members get free access) + const member = await Member.findOne({ email: body.email.toLowerCase() }) + + if (member) { + // Members get free access - register directly without payment + eventData.registrations.push({ + name: body.name, + email: body.email.toLowerCase(), + membershipLevel: `${member.circle}-${member.contributionTier}`, + isMember: true, + paymentStatus: 'not_required', + amountPaid: 0 + }) + + await eventData.save() + + return { + success: true, + message: 'Successfully registered as a member (no payment required)', + registration: eventData.registrations[eventData.registrations.length - 1] + } + } + + // Process payment for non-members + const paymentResult = await processHelcimPayment({ + amount: eventData.pricing.publicPrice, + paymentToken: body.paymentToken, + customerData: { + name: body.name, + email: body.email + } + }) + + if (!paymentResult.success) { + throw createError({ + statusCode: 400, + statusMessage: paymentResult.message || 'Payment failed' + }) + } + + // Add registration with successful payment + eventData.registrations.push({ + name: body.name, + email: body.email.toLowerCase(), + membershipLevel: 'non-member', + isMember: false, + paymentStatus: 'completed', + paymentId: paymentResult.transactionId, + amountPaid: eventData.pricing.publicPrice + }) + + await eventData.save() + + return { + success: true, + message: 'Payment successful and registered for event', + paymentId: paymentResult.transactionId, + registration: eventData.registrations[eventData.registrations.length - 1] + } + + } catch (error) { + console.error('Error processing event payment:', error) + + if (error.statusCode) { + throw error + } + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to process payment and registration' + }) + } +}) \ No newline at end of file diff --git a/server/api/events/[id]/register.post.js b/server/api/events/[id]/register.post.js index 618f81f..e5ecfe2 100644 --- a/server/api/events/[id]/register.post.js +++ b/server/api/events/[id]/register.post.js @@ -65,27 +65,41 @@ export default defineEventHandler(async (event) => { }) } - // Check member status if event is members-only - if (eventData.membersOnly && body.membershipLevel === 'non-member') { - // Check if email belongs to a member - const member = await Member.findOne({ email: body.email.toLowerCase() }) - - if (!member) { - throw createError({ - statusCode: 403, - statusMessage: 'This event is for members only. Please become a member to register.' - }) - } - - // Update membership level from database - body.membershipLevel = `${member.circle}-${member.contributionTier}` + // Check member status and handle different registration scenarios + const member = await Member.findOne({ email: body.email.toLowerCase() }) + + if (eventData.membersOnly && !member) { + throw createError({ + statusCode: 403, + statusMessage: 'This event is for members only. Please become a member to register.' + }) + } + + // If event requires payment and user is not a member, redirect to payment flow + if (eventData.pricing.paymentRequired && !eventData.pricing.isFree && !member) { + throw createError({ + statusCode: 402, // Payment Required + statusMessage: 'This event requires payment. Please use the payment registration endpoint.' + }) + } + + // Set member status and membership level + let isMember = false + let membershipLevel = 'non-member' + + if (member) { + isMember = true + membershipLevel = `${member.circle}-${member.contributionTier}` } // Add registration eventData.registrations.push({ name: body.name, email: body.email.toLowerCase(), - membershipLevel: body.membershipLevel || 'non-member', + membershipLevel, + isMember, + paymentStatus: 'not_required', // Free events or member registrations + amountPaid: 0, dietary: body.dietary || false, registeredAt: new Date() }) diff --git a/server/api/series/[id].get.js b/server/api/series/[id].get.js new file mode 100644 index 0000000..ea372e3 --- /dev/null +++ b/server/api/series/[id].get.js @@ -0,0 +1,78 @@ +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + const id = getRouterParam(event, 'id') + + if (!id) { + throw createError({ + statusCode: 400, + statusMessage: 'Series ID is required' + }) + } + + // Fetch all events in this series + const events = await Event.find({ + 'series.id': id, + 'series.isSeriesEvent': true + }) + .sort({ 'series.position': 1, startDate: 1 }) + .select('-registrations') + .lean() + + if (events.length === 0) { + throw createError({ + statusCode: 404, + statusMessage: 'Event series not found' + }) + } + + // Get series metadata from the first event + const seriesInfo = events[0].series + + // Calculate series statistics + const now = new Date() + const completedEvents = events.filter(e => e.endDate < now).length + const upcomingEvents = events.filter(e => e.startDate > now).length + const ongoingEvents = events.filter(e => e.startDate <= now && e.endDate >= now).length + + const firstEventDate = events[0].startDate + const lastEventDate = events[events.length - 1].endDate + + // Return series with additional metadata + return { + id: id, + title: seriesInfo.title, + description: seriesInfo.description, + type: seriesInfo.type, + totalEvents: seriesInfo.totalEvents, + startDate: firstEventDate, + endDate: lastEventDate, + events: events.map(e => ({ + ...e, + id: e._id.toString() + })), + statistics: { + totalEvents: events.length, + completedEvents, + upcomingEvents, + ongoingEvents, + isOngoing: firstEventDate <= now && lastEventDate >= now, + isUpcoming: firstEventDate > now, + isCompleted: lastEventDate < now, + totalRegistrations: events.reduce((sum, e) => sum + (e.registrations?.length || 0), 0) + } + } + } catch (error) { + console.error('Error fetching event series:', error) + if (error.statusCode) throw error + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch event series' + }) + } +}) \ No newline at end of file diff --git a/server/api/series/index.get.js b/server/api/series/index.get.js new file mode 100644 index 0000000..1dfd52d --- /dev/null +++ b/server/api/series/index.get.js @@ -0,0 +1,91 @@ +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + + const query = getQuery(event) + + // Build filter for series events only + const filter = { + 'series.isSeriesEvent': true, + isVisible: query.includeHidden === 'true' ? { $exists: true } : true + } + + // Filter by series type + if (query.seriesType) { + filter['series.type'] = query.seriesType + } + + // Filter for upcoming series + if (query.upcoming === 'true') { + filter.startDate = { $gte: new Date() } + } + + // Fetch all series events and group them by series.id + const events = await Event.find(filter) + .sort({ 'series.id': 1, 'series.position': 1, startDate: 1 }) + .select('-registrations') + .lean() + + // Group events by series ID + const seriesMap = new Map() + + events.forEach(event => { + const seriesId = event.series?.id + if (!seriesId) return + + if (!seriesMap.has(seriesId)) { + seriesMap.set(seriesId, { + id: seriesId, + title: event.series.title, + description: event.series.description, + type: event.series.type, + totalEvents: event.series.totalEvents, + events: [], + firstEventDate: event.startDate, + lastEventDate: event.endDate + }) + } + + const series = seriesMap.get(seriesId) + series.events.push({ + ...event, + id: event._id.toString() + }) + + // Update date range + if (event.startDate < series.firstEventDate) { + series.firstEventDate = event.startDate + } + if (event.endDate > series.lastEventDate) { + series.lastEventDate = event.endDate + } + }) + + // Convert to array and add computed fields + const seriesArray = Array.from(seriesMap.values()).map(series => { + const now = new Date() + return { + ...series, + eventCount: series.events.length, + startDate: series.firstEventDate, + endDate: series.lastEventDate, + isOngoing: series.firstEventDate <= now && series.lastEventDate >= now, + isUpcoming: series.firstEventDate > now, + isCompleted: series.lastEventDate < now, + status: series.lastEventDate < now ? 'completed' : + series.firstEventDate <= now && series.lastEventDate >= now ? 'active' : 'upcoming' + } + }) + + return seriesArray + } catch (error) { + console.error('Error fetching event series:', error) + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch event series' + }) + } +}) \ No newline at end of file diff --git a/server/models/event.js b/server/models/event.js index 312f5ca..d160776 100644 --- a/server/models/event.js +++ b/server/models/event.js @@ -39,6 +39,41 @@ const eventSchema = new mongoose.Schema({ isCancelled: { type: Boolean, default: false }, cancellationMessage: String, // Custom message for cancelled events membersOnly: { type: Boolean, default: false }, + // Series information - embedded approach for better performance + series: { + id: String, // Simple string ID to group related events + title: String, // Series title (e.g., "Cooperative Game Development Workshop Series") + description: String, // Series description + type: { + type: String, + enum: ['workshop_series', 'recurring_meetup', 'multi_day', 'course', 'tournament'], + default: 'workshop_series' + }, + position: Number, // Order within the series (e.g., 1 = first event, 2 = second, etc.) + totalEvents: Number, // Total planned events in the series + isSeriesEvent: { type: Boolean, default: false } // Flag to identify series events + }, + // Event pricing for public attendees (deprecated - use tickets instead) + pricing: { + isFree: { type: Boolean, default: true }, + publicPrice: { type: Number, default: 0 }, // Price for non-members + currency: { type: String, default: 'CAD' }, + paymentRequired: { type: Boolean, default: false } + }, + // Ticket configuration + tickets: { + enabled: { type: Boolean, default: false }, + public: { + available: { type: Boolean, default: false }, + name: { type: String, default: 'Public Ticket' }, + description: String, + price: { type: Number, default: 0 }, + quantity: Number, // null = unlimited + sold: { type: Number, default: 0 }, + earlyBirdPrice: Number, + earlyBirdDeadline: Date + } + }, // Circle targeting targetCircles: [{ type: String, @@ -58,6 +93,14 @@ const eventSchema = new mongoose.Schema({ name: String, email: String, membershipLevel: String, + isMember: { type: Boolean, default: false }, + paymentStatus: { + type: String, + enum: ['pending', 'completed', 'failed', 'not_required'], + default: 'not_required' + }, + paymentId: String, // Helcim transaction ID + amountPaid: { type: Number, default: 0 }, registeredAt: { type: Date, default: Date.now } }], createdBy: { type: String, required: true }, diff --git a/server/models/series.js b/server/models/series.js new file mode 100644 index 0000000..bd79067 --- /dev/null +++ b/server/models/series.js @@ -0,0 +1,35 @@ +import mongoose from 'mongoose' + +const seriesSchema = new mongoose.Schema({ + id: { + type: String, + required: true, + unique: true, + validate: { + validator: function(v) { + return /^[a-z0-9-]+$/.test(v); + }, + message: 'Series ID must contain only lowercase letters, numbers, and dashes' + } + }, + title: { type: String, required: true }, + description: { type: String, required: true }, + type: { + type: String, + enum: ['workshop_series', 'recurring_meetup', 'multi_day', 'course', 'tournament'], + default: 'workshop_series' + }, + totalEvents: Number, + isActive: { type: Boolean, default: true }, + createdBy: { type: String, required: true }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now } +}) + +// Update the updatedAt field on save +seriesSchema.pre('save', function(next) { + this.updatedAt = new Date() + next() +}) + +export default mongoose.models.Series || mongoose.model('Series', seriesSchema) \ No newline at end of file From 2ca290d6e09dc1846500baea842e89e95f54aef0 Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 3 Sep 2025 14:47:13 +0100 Subject: [PATCH 2/4] Implement multi-step registration process: Add step indicators, error handling, and payment processing for membership registration. Enhance form validation and user feedback with success and error messages. Refactor state management for improved clarity and maintainability. --- HELCIM_TEST_INSTRUCTIONS.md | 120 ++++++ UPDATE_SUMMARY.md | 57 +++ app/composables/useAuth.js | 48 +++ app/composables/useHelcim.js | 90 +++++ app/composables/useHelcimPay.js | 158 ++++++++ app/pages/admin/events/create.vue | 167 +------- app/pages/events/[id].vue | 105 +++-- app/pages/join.vue | 402 ++++++++++++++++++- debug-token.js | 42 ++ server/api/auth/member.get.js | 43 ++ server/api/helcim/customer.post.js | 136 +++++++ server/api/helcim/initialize-payment.post.js | 62 +++ server/api/helcim/plans.get.js | 45 +++ server/api/helcim/subscription.post.js | 282 +++++++++++++ server/api/helcim/subscriptions.get.js | 45 +++ server/api/helcim/test-connection.get.js | 46 +++ server/api/helcim/test-subscription.get.js | 77 ++++ server/api/helcim/update-billing.post.js | 71 ++++ server/api/helcim/verify-payment.post.js | 38 ++ server/config/contributions.js | 111 +++++ server/models/member.js | 13 + test-helcim-direct.js | 49 +++ 22 files changed, 1994 insertions(+), 213 deletions(-) create mode 100644 HELCIM_TEST_INSTRUCTIONS.md create mode 100644 UPDATE_SUMMARY.md create mode 100644 app/composables/useAuth.js create mode 100644 app/composables/useHelcim.js create mode 100644 app/composables/useHelcimPay.js create mode 100644 debug-token.js create mode 100644 server/api/auth/member.get.js create mode 100644 server/api/helcim/customer.post.js create mode 100644 server/api/helcim/initialize-payment.post.js create mode 100644 server/api/helcim/plans.get.js create mode 100644 server/api/helcim/subscription.post.js create mode 100644 server/api/helcim/subscriptions.get.js create mode 100644 server/api/helcim/test-connection.get.js create mode 100644 server/api/helcim/test-subscription.get.js create mode 100644 server/api/helcim/update-billing.post.js create mode 100644 server/api/helcim/verify-payment.post.js create mode 100644 server/config/contributions.js create mode 100644 test-helcim-direct.js diff --git a/HELCIM_TEST_INSTRUCTIONS.md b/HELCIM_TEST_INSTRUCTIONS.md new file mode 100644 index 0000000..d1534e8 --- /dev/null +++ b/HELCIM_TEST_INSTRUCTIONS.md @@ -0,0 +1,120 @@ +# Helcim Integration Testing Guide + +## Setup Complete +The Helcim Recurring API integration has been set up with the following components: + +### 1. Composables +- `/app/composables/useHelcim.js` - Server-side Helcim API interactions +- `/app/composables/useHelcimPay.js` - Client-side HelcimPay.js integration + +### 2. Server API Endpoints +- `/server/api/helcim/customer.post.js` - Creates Helcim customer and member record +- `/server/api/helcim/subscription.post.js` - Creates subscription for paid tiers +- `/server/api/helcim/verify-payment.post.js` - Verifies payment token + +### 3. Updated Pages +- `/app/pages/join.vue` - Multi-step signup flow with payment integration + +### 4. Database Schema +- Updated `/server/models/member.js` with subscription fields + +## Testing Instructions + +### Prerequisites +1. Ensure your `.env` file has the test Helcim token: + ``` + NUXT_PUBLIC_HELCIM_TOKEN=your_test_token_here + ``` + +2. Ensure you have test payment plans created in Helcim dashboard matching these IDs: + - `supporter-monthly-5` + - `member-monthly-15` + - `advocate-monthly-30` + - `champion-monthly-50` + +### Test Flow + +#### 1. Start the Development Server +```bash +npm run dev +``` + +#### 2. Test Free Tier Signup +1. Navigate to `/join` +2. Fill in name and email +3. Select any circle +4. Choose "$0 - I need support right now" +5. Click "Complete Registration" +6. Should go directly to confirmation without payment + +#### 3. Test Paid Tier Signup +1. Navigate to `/join` +2. Fill in test details: + - Name: Test User + - Email: test@example.com +3. Select any circle +4. Choose a paid contribution tier (e.g., "$15 - I can sustain the community") +5. Click "Continue to Payment" +6. On the payment step, use Helcim test card numbers: + - **Success**: 4111 1111 1111 1111 + - **Decline**: 4000 0000 0000 0002 + - CVV: Any 3 digits + - Expiry: Any future date +7. Click "Complete Payment" +8. Should see confirmation with member details + +### Test Card Numbers (Helcim Test Mode) +- **Visa Success**: 4111 1111 1111 1111 +- **Mastercard Success**: 5500 0000 0000 0004 +- **Amex Success**: 3400 0000 0000 009 +- **Decline**: 4000 0000 0000 0002 +- **Insufficient Funds**: 4000 0000 0000 0051 + +### Debugging + +#### Check API Responses +Open browser DevTools Network tab to monitor: +- `/api/helcim/customer` - Should return customer ID and token +- `/api/helcim/verify-payment` - Should return card details +- `/api/helcim/subscription` - Should return subscription ID + +#### Common Issues + +1. **HelcimPay.js not loading** + - Check console for script loading errors + - Verify token is correctly set in environment + +2. **Customer creation fails** + - Check API token permissions in Helcim dashboard + - Verify MongoDB connection + +3. **Payment verification fails** + - Ensure you're using test card numbers + - Check that Helcim account is in test mode + +4. **Subscription creation fails** + - Verify payment plan IDs exist in Helcim + - Check that card token was successfully captured + +### Database Verification + +Check MongoDB for created records: +```javascript +// In MongoDB shell or client +db.members.findOne({ email: "test@example.com" }) +``` + +Should see: +- `helcimCustomerId` populated +- `helcimSubscriptionId` for paid tiers +- `status: "active"` after successful payment +- `paymentMethod: "card"` for paid tiers + +## Next Steps + +Once testing is successful: +1. Switch to production Helcim token +2. Create production payment plans in Helcim +3. Update plan IDs in `/app/config/contributions.js` if needed +4. Test with real payment card (small amount) +5. Set up webhook endpoints for subscription events (renewals, failures, cancellations) \ No newline at end of file diff --git a/UPDATE_SUMMARY.md b/UPDATE_SUMMARY.md new file mode 100644 index 0000000..5c7d439 --- /dev/null +++ b/UPDATE_SUMMARY.md @@ -0,0 +1,57 @@ +# Helcim Integration - Issues Fixed + +## Problem +The API was returning 401 Unauthorized when trying to create customers. + +## Root Cause +The runtime config wasn't properly accessing the Helcim token in server-side endpoints. + +## Solution Applied + +### 1. Fixed Runtime Config Access +Updated all server endpoints to: +- Pass the `event` parameter to `useRuntimeConfig(event)` +- Fallback to `process.env.NUXT_PUBLIC_HELCIM_TOKEN` if config doesn't load + +### 2. Files Updated +- `/server/api/helcim/customer.post.js` +- `/server/api/helcim/subscription.post.js` +- `/server/api/helcim/verify-payment.post.js` +- `/server/api/helcim/test-connection.get.js` + +### 3. Fixed Import Path +Created `/server/config/contributions.js` to re-export the contributions config for server-side imports. + +### 4. Verified Token Works +Created `test-helcim-direct.js` which successfully: +- Connected to Helcim API +- Created a test customer (ID: 32854583, Code: CST1000) + +## Testing Instructions + +1. Restart your development server: + ```bash + npm run dev + ``` + +2. Test the connection: + ```bash + curl http://localhost:3000/api/helcim/test-connection + ``` + +3. Try the signup flow at `/join` + +## Important Notes + +- The token in your `.env` file is working correctly +- The Helcim API is accessible and responding +- Customer creation is functional when called directly +- The issue was specifically with how Nuxt's runtime config was being accessed in server endpoints + +## Next Steps + +Once you confirm the signup flow works: +1. Test with different contribution tiers +2. Verify payment capture with test cards +3. Check that subscriptions are created correctly +4. Consider adding webhook endpoints for subscription events \ No newline at end of file diff --git a/app/composables/useAuth.js b/app/composables/useAuth.js new file mode 100644 index 0000000..807538e --- /dev/null +++ b/app/composables/useAuth.js @@ -0,0 +1,48 @@ +export const useAuth = () => { + const authCookie = useCookie('auth-token') + const memberData = useState('auth.member', () => null) + const isAuthenticated = computed(() => !!authCookie.value) + const isMember = computed(() => !!memberData.value) + + const checkMemberStatus = async () => { + if (!authCookie.value) { + memberData.value = null + return false + } + + try { + const response = await $fetch('/api/auth/member', { + headers: { + 'Cookie': `auth-token=${authCookie.value}` + } + }) + memberData.value = response + return true + } catch (error) { + console.error('Failed to fetch member status:', error) + memberData.value = null + return false + } + } + + const logout = async () => { + try { + await $fetch('/api/auth/logout', { + method: 'POST' + }) + authCookie.value = null + memberData.value = null + await navigateTo('/') + } catch (error) { + console.error('Logout failed:', error) + } + } + + return { + isAuthenticated: readonly(isAuthenticated), + isMember: readonly(isMember), + memberData: readonly(memberData), + checkMemberStatus, + logout + } +} \ No newline at end of file diff --git a/app/composables/useHelcim.js b/app/composables/useHelcim.js new file mode 100644 index 0000000..efc96b1 --- /dev/null +++ b/app/composables/useHelcim.js @@ -0,0 +1,90 @@ +// Helcim API integration composable +export const useHelcim = () => { + const config = useRuntimeConfig() + const helcimToken = config.public.helcimToken + + // Base URL for Helcim API + const HELCIM_API_BASE = 'https://api.helcim.com/v2' + + // Helper function to make API requests + const makeHelcimRequest = async (endpoint, method = 'GET', body = null) => { + try { + const response = await $fetch(`${HELCIM_API_BASE}${endpoint}`, { + method, + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + 'api-token': helcimToken + }, + body: body ? JSON.stringify(body) : undefined + }) + return response + } catch (error) { + console.error('Helcim API error:', error) + throw error + } + } + + // Create a customer + const createCustomer = async (customerData) => { + return await makeHelcimRequest('/customers', 'POST', { + customerType: 'PERSON', + contactName: customerData.name, + email: customerData.email, + billingAddress: customerData.billingAddress || {} + }) + } + + // Create a subscription + const createSubscription = async (customerId, planId, cardToken) => { + return await makeHelcimRequest('/recurring/subscriptions', 'POST', { + customerId, + planId, + cardToken, + startDate: new Date().toISOString().split('T')[0] // Today's date + }) + } + + // Get customer details + const getCustomer = async (customerId) => { + return await makeHelcimRequest(`/customers/${customerId}`) + } + + // Get subscription details + const getSubscription = async (subscriptionId) => { + return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`) + } + + // Update subscription + const updateSubscription = async (subscriptionId, updates) => { + return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'PATCH', updates) + } + + // Cancel subscription + const cancelSubscription = async (subscriptionId) => { + return await makeHelcimRequest(`/recurring/subscriptions/${subscriptionId}`, 'DELETE') + } + + // Get payment plans + const getPaymentPlans = async () => { + return await makeHelcimRequest('/recurring/plans') + } + + // Verify card token (for testing) + const verifyCardToken = async (cardToken) => { + return await makeHelcimRequest('/cards/verify', 'POST', { + cardToken + }) + } + + return { + createCustomer, + createSubscription, + getCustomer, + getSubscription, + updateSubscription, + cancelSubscription, + getPaymentPlans, + verifyCardToken + } +} \ No newline at end of file diff --git a/app/composables/useHelcimPay.js b/app/composables/useHelcimPay.js new file mode 100644 index 0000000..9f40d07 --- /dev/null +++ b/app/composables/useHelcimPay.js @@ -0,0 +1,158 @@ +// HelcimPay.js integration composable +export const useHelcimPay = () => { + let checkoutToken = null + let secretToken = null + + // Initialize HelcimPay.js session + const initializeHelcimPay = async (customerId, customerCode, amount = 0) => { + try { + const response = await $fetch('/api/helcim/initialize-payment', { + method: 'POST', + body: { + customerId, + customerCode, + amount + } + }) + + if (response.success) { + checkoutToken = response.checkoutToken + secretToken = response.secretToken + return true + } + + throw new Error('Failed to initialize payment session') + } catch (error) { + console.error('Payment initialization error:', error) + throw error + } + } + + // Show payment modal + const showPaymentModal = () => { + return new Promise((resolve, reject) => { + if (!checkoutToken) { + reject(new Error('Payment not initialized. Call initializeHelcimPay first.')) + return + } + + // Load HelcimPay.js modal script + if (!window.appendHelcimPayIframe) { + console.log('HelcimPay script not loaded, loading now...') + const script = document.createElement('script') + script.src = 'https://secure.helcim.app/helcim-pay/services/start.js' + script.async = true + script.onload = () => { + console.log('HelcimPay script loaded successfully!') + console.log('Available functions:', Object.keys(window).filter(key => key.includes('Helcim') || key.includes('helcim'))) + console.log('appendHelcimPayIframe available:', typeof window.appendHelcimPayIframe) + openModal(resolve, reject) + } + script.onerror = () => { + reject(new Error('Failed to load HelcimPay.js')) + } + document.head.appendChild(script) + } else { + console.log('HelcimPay script already loaded, calling openModal') + openModal(resolve, reject) + } + }) + } + + // Open the payment modal + const openModal = (resolve, reject) => { + try { + console.log('Trying to open modal with checkoutToken:', checkoutToken) + + if (typeof window.appendHelcimPayIframe === 'function') { + // Set up event listener for HelcimPay.js responses + const helcimPayJsIdentifierKey = 'helcim-pay-js-' + checkoutToken + + const handleHelcimPayEvent = (event) => { + console.log('Received window message:', event.data) + + if (event.data.eventName === helcimPayJsIdentifierKey) { + console.log('HelcimPay event received:', event.data) + + // Remove event listener to prevent multiple responses + window.removeEventListener('message', handleHelcimPayEvent) + + if (event.data.eventStatus === 'SUCCESS') { + console.log('Payment success:', event.data.eventMessage) + + // Parse the JSON string eventMessage + let paymentData + try { + paymentData = JSON.parse(event.data.eventMessage) + console.log('Parsed payment data:', paymentData) + } catch (parseError) { + console.error('Failed to parse eventMessage:', parseError) + reject(new Error('Invalid payment response format')) + return + } + + // Extract transaction details from nested data structure + const transactionData = paymentData.data?.data || {} + console.log('Transaction data:', transactionData) + + resolve({ + success: true, + transactionId: transactionData.transactionId, + cardToken: transactionData.cardToken, + cardLast4: transactionData.cardNumber ? transactionData.cardNumber.slice(-4) : undefined, + cardType: transactionData.cardType || 'unknown' + }) + } else if (event.data.eventStatus === 'ABORTED') { + console.log('Payment aborted:', event.data.eventMessage) + reject(new Error(event.data.eventMessage || 'Payment failed')) + } else if (event.data.eventStatus === 'HIDE') { + console.log('Modal closed without completion') + reject(new Error('Payment cancelled by user')) + } + } + } + + // Add event listener + window.addEventListener('message', handleHelcimPayEvent) + + // Open the HelcimPay iframe modal + console.log('Calling appendHelcimPayIframe with token:', checkoutToken) + window.appendHelcimPayIframe(checkoutToken, true) + console.log('appendHelcimPayIframe called, waiting for window messages...') + + // Add timeout to clean up if no response + setTimeout(() => { + console.log('60 seconds passed, cleaning up event listener...') + window.removeEventListener('message', handleHelcimPayEvent) + reject(new Error('Payment timeout - no response received')) + }, 60000) + } else { + reject(new Error('appendHelcimPayIframe function not available')) + } + } catch (error) { + console.error('Error opening modal:', error) + reject(error) + } + } + + // Process payment verification + const verifyPayment = async () => { + try { + return await showPaymentModal() + } catch (error) { + throw error + } + } + + // Cleanup tokens + const cleanup = () => { + checkoutToken = null + secretToken = null + } + + return { + initializeHelcimPay, + verifyPayment, + cleanup + } +} \ No newline at end of file diff --git a/app/pages/admin/events/create.vue b/app/pages/admin/events/create.vue index 81db3e0..6bab075 100644 --- a/app/pages/admin/events/create.vue +++ b/app/pages/admin/events/create.vue @@ -390,40 +390,6 @@
-
-
- - -

- {{ selectedSeriesId ? 'From selected series' : 'Unique identifier to group related events (use lowercase with dashes)' }} -

-
- -
- - -

Order within the series (1, 2, 3, etc.)

-
-
-
-
- - -
- -
- - -

{{ selectedSeriesId ? 'From selected series' : 'How many events will be in this series?' }}

-
-

@@ -617,8 +552,6 @@ const editingEvent = ref(null) const showSuccessMessage = ref(false) const formErrors = ref([]) const fieldErrors = ref({}) -const seriesExists = ref(false) -const existingSeries = ref(null) const selectedSeriesId = ref('') const availableSeries = ref([]) @@ -655,10 +588,7 @@ const eventForm = reactive({ isSeriesEvent: false, id: '', title: '', - description: '', - type: 'workshop_series', - position: 1, - totalEvents: null + description: '' } }) @@ -680,18 +610,12 @@ const onSeriesSelect = () => { eventForm.series.id = series.id eventForm.series.title = series.title eventForm.series.description = series.description - eventForm.series.type = series.type - eventForm.series.totalEvents = series.totalEvents - eventForm.series.position = (series.eventCount || 0) + 1 } } else { // Reset series form when no series is selected eventForm.series.id = '' eventForm.series.title = '' eventForm.series.description = '' - eventForm.series.type = 'workshop_series' - eventForm.series.position = 1 - eventForm.series.totalEvents = null } } @@ -736,10 +660,7 @@ if (route.query.edit) { isSeriesEvent: false, id: '', title: '', - description: '', - type: 'workshop_series', - position: 1, - totalEvents: null + description: '' } }) // Handle early bird deadline formatting @@ -851,62 +772,6 @@ const validateForm = () => { return formErrors.value.length === 0 } -// Check if a series with this ID already exists -const checkExistingSeries = async () => { - if (!eventForm.series.id || selectedSeriesId.value) { - seriesExists.value = false - existingSeries.value = null - return - } - - try { - // First check in standalone series - const standaloneResponse = await $fetch(`/api/admin/series`) - const existingStandalone = standaloneResponse.find(s => s.id === eventForm.series.id) - - if (existingStandalone) { - seriesExists.value = true - existingSeries.value = existingStandalone - // Auto-fill series details - if (!eventForm.series.title || eventForm.series.title === '') { - eventForm.series.title = existingStandalone.title - } - if (!eventForm.series.description || eventForm.series.description === '') { - eventForm.series.description = existingStandalone.description - } - if (!eventForm.series.type || eventForm.series.type === 'workshop_series') { - eventForm.series.type = existingStandalone.type - } - if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) { - eventForm.series.totalEvents = existingStandalone.totalEvents - } - return - } - - // Fallback to legacy series check (events with series data) - const legacyResponse = await $fetch(`/api/series/${eventForm.series.id}`) - if (legacyResponse) { - seriesExists.value = true - existingSeries.value = legacyResponse - if (!eventForm.series.title || eventForm.series.title === '') { - eventForm.series.title = legacyResponse.title - } - if (!eventForm.series.description || eventForm.series.description === '') { - eventForm.series.description = legacyResponse.description - } - if (!eventForm.series.type || eventForm.series.type === 'workshop_series') { - eventForm.series.type = legacyResponse.type - } - if (!eventForm.series.totalEvents || eventForm.series.totalEvents === null) { - eventForm.series.totalEvents = legacyResponse.totalEvents - } - } - } catch (error) { - // Series doesn't exist yet - seriesExists.value = false - existingSeries.value = null - } -} const saveEvent = async (redirect = true) => { if (!validateForm()) { @@ -918,24 +783,11 @@ const saveEvent = async (redirect = true) => { creating.value = true try { // If this is a series event and not using an existing series, create the standalone series first - if (eventForm.series.isSeriesEvent && eventForm.series.id && !selectedSeriesId.value) { - try { - await $fetch('/api/admin/series', { - method: 'POST', - body: { - id: eventForm.series.id, - title: eventForm.series.title, - description: eventForm.series.description, - type: eventForm.series.type, - totalEvents: eventForm.series.totalEvents - } - }) - } catch (seriesError) { - // Series might already exist, that's ok - if (!seriesError.data?.statusMessage?.includes('already exists')) { - throw seriesError - } - } + if (eventForm.series.isSeriesEvent && selectedSeriesId.value) { + // Series will be handled by the selected series + } else if (eventForm.series.isSeriesEvent) { + // For now, series creation requires selecting an existing series + // Individual series creation is handled through the series management page } if (editingEvent.value) { @@ -1007,10 +859,7 @@ const saveAndCreateAnother = async () => { isSeriesEvent: false, id: '', title: '', - description: '', - type: 'workshop_series', - position: 1, - totalEvents: null + description: '' } }) diff --git a/app/pages/events/[id].vue b/app/pages/events/[id].vue index ee3d3b7..06cc52f 100644 --- a/app/pages/events/[id].vue +++ b/app/pages/events/[id].vue @@ -196,58 +196,71 @@

-
- - -
+ +
{{ isRegistering ? 'Registering...' : 'Register for Event' }} + + + Become a Member to Register + +
@@ -302,8 +315,20 @@ if (error.value?.statusCode === 404) { }) } -// Check if user is a member (this would normally come from auth/store) -const isMember = ref(false) // Set to true if user is logged in and is a member +// Authentication +const { isMember, memberData, checkMemberStatus } = useAuth() + +// Check member status on mount +onMounted(async () => { + await checkMemberStatus() + + // Pre-fill form if member is logged in + if (memberData.value) { + registrationForm.value.name = memberData.value.name + registrationForm.value.email = memberData.value.email + registrationForm.value.membershipLevel = memberData.value.membershipLevel || 'non-member' + } +}) // Registration form state const registrationForm = ref({ diff --git a/app/pages/join.vue b/app/pages/join.vue index 8413dd1..60d865f 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -20,7 +20,81 @@

-
+ +
+
+
+
+ 1 +
+ + Information + +
+ +
+
+
+ +
+
+ 2 +
+ + Payment + +
+ +
+
+
+ +
+
+ 3 + 2 +
+ + Confirmation + +
+
+
+ + +
+ +
+ + +
@@ -113,11 +187,118 @@ size="xl" class="px-12" > - Continue to Payment + {{ needsPayment ? 'Continue to Payment' : 'Complete Registration' }}
+ + +
+
+

+ Payment Information +

+

+ You're signing up for the {{ selectedTier.label }} plan +

+

+ ${{ selectedTier.amount }} CAD / month +

+
+ + +
+

+ Click "Complete Payment" below to open the secure payment modal and verify your payment method. +

+
+ + +
+ + Back + + + Complete Payment + +
+
+ + +
+
+
+ + + +
+ +

+ Welcome to Ghost Guild! +

+ +
+ +
+ +
+

Membership Details:

+
+
+
Name:
+
{{ form.name }}
+
+
+
Email:
+
{{ form.email }}
+
+
+
Circle:
+
{{ form.circle }}
+
+
+
Contribution:
+
{{ selectedTier.label }}
+
+
+
Member ID:
+
{{ customerCode }}
+
+
+
+ +

+ We've sent a confirmation email to {{ form.email }} with your membership details. +

+ +
+ + Go to Dashboard + + + Register Another Member + +
+
+
@@ -315,9 +496,9 @@ \ No newline at end of file diff --git a/debug-token.js b/debug-token.js new file mode 100644 index 0000000..59f7b9b --- /dev/null +++ b/debug-token.js @@ -0,0 +1,42 @@ +// Debug token encoding +const originalToken = 'aG_Eu%lqXCIJdWb2fUx52P_*-9GzaUHAVXvRjF43#sZw_FEeV9q7gl$pe$1EPRNs' +// Manually fix the %lq part - it should be a literal character, not URL encoded +const correctedToken = originalToken.replace('%lq', 'lq') + +console.log('Original token:', originalToken) +console.log('Corrected token:', correctedToken) +console.log('Are they different?', originalToken !== correctedToken) + +async function testBoth() { + console.log('\n=== Testing Original Token ===') + try { + const response1 = await fetch('https://api.helcim.com/v2/connection-test', { + headers: { + 'accept': 'application/json', + 'api-token': originalToken + } + }) + console.log('Original token status:', response1.status) + const data1 = await response1.text() + console.log('Original token response:', data1) + } catch (error) { + console.error('Original token error:', error.message) + } + + console.log('\n=== Testing Corrected Token ===') + try { + const response2 = await fetch('https://api.helcim.com/v2/connection-test', { + headers: { + 'accept': 'application/json', + 'api-token': correctedToken + } + }) + console.log('Corrected token status:', response2.status) + const data2 = await response2.text() + console.log('Corrected token response:', data2) + } catch (error) { + console.error('Corrected token error:', error.message) + } +} + +testBoth() \ No newline at end of file diff --git a/server/api/auth/member.get.js b/server/api/auth/member.get.js new file mode 100644 index 0000000..259dcc9 --- /dev/null +++ b/server/api/auth/member.get.js @@ -0,0 +1,43 @@ +import jwt from 'jsonwebtoken' +import Member from '../../models/member.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + await connectDB() + + const token = getCookie(event, 'auth-token') + + if (!token) { + throw createError({ + statusCode: 401, + statusMessage: 'Not authenticated' + }) + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET) + const member = await Member.findById(decoded.memberId).select('-__v') + + if (!member) { + throw createError({ + statusCode: 404, + statusMessage: 'Member not found' + }) + } + + return { + id: member._id, + email: member.email, + name: member.name, + circle: member.circle, + contributionTier: member.contributionTier, + membershipLevel: `${member.circle}-${member.contributionTier}` + } + } catch (err) { + console.error('Token verification error:', err) + throw createError({ + statusCode: 401, + statusMessage: 'Invalid or expired token' + }) + } +}) \ No newline at end of file diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js new file mode 100644 index 0000000..d7e3454 --- /dev/null +++ b/server/api/helcim/customer.post.js @@ -0,0 +1,136 @@ +// Create a Helcim customer +import jwt from 'jsonwebtoken' +import Member from '../../models/member.js' +import { connectDB } from '../../utils/mongoose.js' + +const HELCIM_API_BASE = 'https://api.helcim.com/v2' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + const config = useRuntimeConfig(event) + const body = await readBody(event) + + // Validate required fields + if (!body.name || !body.email) { + throw createError({ + statusCode: 400, + statusMessage: 'Name and email are required' + }) + } + + // Check if member already exists + const existingMember = await Member.findOne({ email: body.email }) + if (existingMember) { + throw createError({ + statusCode: 409, + statusMessage: 'A member with this email already exists' + }) + } + + // Get token directly from environment if not in config + const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN + + if (!helcimToken) { + throw createError({ + statusCode: 500, + statusMessage: 'Helcim API token not configured' + }) + } + + // Debug: Log token (first few chars only) + console.log('Using Helcim token:', helcimToken.substring(0, 10) + '...') + + // Test the connection first with native fetch + try { + const testResponse = await fetch('https://api.helcim.com/v2/connection-test', { + method: 'GET', + headers: { + 'accept': 'application/json', + 'api-token': helcimToken + } + }) + + if (!testResponse.ok) { + throw new Error(`HTTP ${testResponse.status}: ${testResponse.statusText}`) + } + + const testData = await testResponse.json() + console.log('Connection test passed:', testData) + } catch (testError) { + console.error('Connection test failed:', testError) + throw createError({ + statusCode: 401, + statusMessage: `Helcim API connection failed: ${testError.message}` + }) + } + + // Create customer in Helcim using native fetch + const customerResponse = await fetch(`${HELCIM_API_BASE}/customers`, { + method: 'POST', + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + 'api-token': helcimToken + }, + body: JSON.stringify({ + customerType: 'PERSON', + contactName: body.name, + email: body.email + }) + }) + + if (!customerResponse.ok) { + const errorText = await customerResponse.text() + console.error('Customer creation failed:', customerResponse.status, errorText) + throw createError({ + statusCode: customerResponse.status, + statusMessage: `Failed to create customer: ${errorText}` + }) + } + + const customerData = await customerResponse.json() + + // Create member in database + const member = await Member.create({ + email: body.email, + name: body.name, + circle: body.circle, + contributionTier: body.contributionTier, + helcimCustomerId: customerData.id, + status: 'pending_payment' + }) + + // Generate JWT token for the session + const token = jwt.sign( + { + memberId: member._id, + email: body.email, + helcimCustomerId: customerData.id + }, + config.jwtSecret, + { expiresIn: '24h' } + ) + + return { + success: true, + customerId: customerData.id, + customerCode: customerData.customerCode, + token, + member: { + id: member._id, + email: member.email, + name: member.name, + circle: member.circle, + contributionTier: member.contributionTier, + status: member.status + } + } + } catch (error) { + console.error('Error creating Helcim customer:', error) + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.message || 'Failed to create customer' + }) + } +}) \ No newline at end of file diff --git a/server/api/helcim/initialize-payment.post.js b/server/api/helcim/initialize-payment.post.js new file mode 100644 index 0000000..d17a715 --- /dev/null +++ b/server/api/helcim/initialize-payment.post.js @@ -0,0 +1,62 @@ +// Initialize HelcimPay.js session +const HELCIM_API_BASE = 'https://api.helcim.com/v2' + +export default defineEventHandler(async (event) => { + try { + const config = useRuntimeConfig(event) + const body = await readBody(event) + + // Debug log the request body + console.log('Initialize payment request body:', body) + + // Validate required fields + if (!body.customerId) { + throw createError({ + statusCode: 400, + statusMessage: 'Customer ID is required' + }) + } + + const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN + + // Initialize HelcimPay.js session + const response = await fetch(`${HELCIM_API_BASE}/helcim-pay/initialize`, { + method: 'POST', + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + 'api-token': helcimToken + }, + body: JSON.stringify({ + paymentType: 'verify', // For card verification + amount: 0, // Must be exactly 0 for verification + currency: 'CAD', + customerCode: body.customerCode, + paymentMethod: 'cc' + }) + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('HelcimPay initialization failed:', response.status, errorText) + throw createError({ + statusCode: response.status, + statusMessage: `Failed to initialize payment: ${errorText}` + }) + } + + const paymentData = await response.json() + + return { + success: true, + checkoutToken: paymentData.checkoutToken, + secretToken: paymentData.secretToken + } + } catch (error) { + console.error('Error initializing HelcimPay:', error) + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.message || 'Failed to initialize payment' + }) + } +}) \ No newline at end of file diff --git a/server/api/helcim/plans.get.js b/server/api/helcim/plans.get.js new file mode 100644 index 0000000..e4da1ce --- /dev/null +++ b/server/api/helcim/plans.get.js @@ -0,0 +1,45 @@ +// Get Helcim payment plans +const HELCIM_API_BASE = 'https://api.helcim.com/v2' + +export default defineEventHandler(async (event) => { + try { + const config = useRuntimeConfig(event) + const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN + + console.log('Fetching payment plans from Helcim...') + + const response = await fetch(`${HELCIM_API_BASE}/payment-plans`, { + method: 'GET', + headers: { + 'accept': 'application/json', + 'api-token': helcimToken + } + }) + + if (!response.ok) { + console.error('Failed to fetch payment plans:', response.status, response.statusText) + const errorText = await response.text() + console.error('Response body:', errorText) + + throw createError({ + statusCode: response.status, + statusMessage: `Failed to fetch payment plans: ${errorText}` + }) + } + + const plansData = await response.json() + console.log('Payment plans retrieved:', JSON.stringify(plansData, null, 2)) + + return { + success: true, + plans: plansData + } + + } catch (error) { + console.error('Error fetching Helcim payment plans:', error) + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.message || 'Failed to fetch payment plans' + }) + } +}) \ No newline at end of file diff --git a/server/api/helcim/subscription.post.js b/server/api/helcim/subscription.post.js new file mode 100644 index 0000000..1e0155f --- /dev/null +++ b/server/api/helcim/subscription.post.js @@ -0,0 +1,282 @@ +// Create a Helcim subscription +import { getHelcimPlanId, requiresPayment } from '../../config/contributions.js' +import Member from '../../models/member.js' +import { connectDB } from '../../utils/mongoose.js' + +const HELCIM_API_BASE = 'https://api.helcim.com/v2' + +export default defineEventHandler(async (event) => { + try { + await connectDB() + const config = useRuntimeConfig(event) + const body = await readBody(event) + + // Validate required fields + if (!body.customerId || !body.contributionTier) { + throw createError({ + statusCode: 400, + statusMessage: 'Customer ID and contribution tier are required' + }) + } + + if (!body.customerCode) { + throw createError({ + statusCode: 400, + statusMessage: 'Customer code is required for subscription creation' + }) + } + + console.log('Subscription request body:', body) + + // Check if payment is required + if (!requiresPayment(body.contributionTier)) { + console.log('No payment required for tier:', body.contributionTier) + // For free tier, just update member status + const member = await Member.findOneAndUpdate( + { helcimCustomerId: body.customerId }, + { + status: 'active', + contributionTier: body.contributionTier, + subscriptionStartDate: new Date() + }, + { new: true } + ) + + console.log('Updated member for free tier:', member) + + return { + success: true, + subscription: null, + member + } + } + + console.log('Payment required for tier:', body.contributionTier) + + // Get the Helcim plan ID + const planId = getHelcimPlanId(body.contributionTier) + console.log('Plan ID for tier:', planId) + + // Validate card token is provided + if (!body.cardToken) { + throw createError({ + statusCode: 400, + statusMessage: 'Payment information is required for this contribution tier' + }) + } + + // Check if we have a configured plan for this tier + if (!planId) { + console.log('No Helcim plan configured for tier:', body.contributionTier) + + const member = await Member.findOneAndUpdate( + { helcimCustomerId: body.customerId }, + { + status: 'active', + contributionTier: body.contributionTier, + subscriptionStartDate: new Date(), + paymentMethod: 'card', + cardToken: body.cardToken, + notes: `Payment successful but no Helcim plan configured for tier ${body.contributionTier}` + }, + { new: true } + ) + + return { + success: true, + subscription: { + subscriptionId: 'manual-' + Date.now(), + status: 'needs_plan_setup', + nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + }, + member, + warning: `Payment successful but recurring plan needs to be set up in Helcim for the ${body.contributionTier} tier` + } + } + + // Try to create subscription in Helcim + const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN + + console.log('Attempting to create Helcim subscription with plan ID:', planId) + + // Generate a proper alphanumeric idempotency key (exactly 25 characters) + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + let idempotencyKey = '' + for (let i = 0; i < 25; i++) { + idempotencyKey += chars.charAt(Math.floor(Math.random() * chars.length)) + } + + // Get contribution tier details to set recurring amount + const { getContributionTierByValue } = await import('../../config/contributions.js') + const tierInfo = getContributionTierByValue(body.contributionTier) + + const requestBody = { + subscriptions: [{ + dateActivated: new Date().toISOString().split('T')[0], // Today in YYYY-MM-DD format + paymentPlanId: parseInt(planId), + customerCode: body.customerCode, + recurringAmount: parseFloat(tierInfo.amount), + paymentMethod: 'card' + }] + } + const requestHeaders = { + 'accept': 'application/json', + 'content-type': 'application/json', + 'api-token': helcimToken, + 'idempotency-key': idempotencyKey + } + + console.log('Subscription request body:', requestBody) + console.log('Request headers:', requestHeaders) + console.log('Request URL:', `${HELCIM_API_BASE}/subscriptions`) + + try { + const subscriptionResponse = await fetch(`${HELCIM_API_BASE}/subscriptions`, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(requestBody) + }) + + if (!subscriptionResponse.ok) { + const errorText = await subscriptionResponse.text() + console.error('Subscription creation failed:') + console.error('Status:', subscriptionResponse.status) + console.error('Status Text:', subscriptionResponse.statusText) + console.error('Headers:', Object.fromEntries(subscriptionResponse.headers.entries())) + console.error('Response Body:', errorText) + console.error('Request was:', { + url: `${HELCIM_API_BASE}/subscriptions`, + method: 'POST', + body: requestBody, + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + 'api-token': helcimToken ? 'present' : 'missing' + } + }) + + // If it's a validation error, let's try to get more info about available plans + if (subscriptionResponse.status === 400 || subscriptionResponse.status === 404) { + console.log('Plan might not exist. Trying to get list of available payment plans...') + + // Try to fetch available payment plans + try { + const plansResponse = await fetch(`${HELCIM_API_BASE}/payment-plans`, { + method: 'GET', + headers: { + 'accept': 'application/json', + 'api-token': helcimToken + } + }) + + if (plansResponse.ok) { + const plansData = await plansResponse.json() + console.log('Available payment plans:', JSON.stringify(plansData, null, 2)) + } else { + console.log('Could not fetch payment plans:', plansResponse.status, plansResponse.statusText) + } + } catch (planError) { + console.log('Error fetching payment plans:', planError.message) + } + + // For now, just update member status and let user know we need to configure plans + const member = await Member.findOneAndUpdate( + { helcimCustomerId: body.customerId }, + { + status: 'active', + contributionTier: body.contributionTier, + subscriptionStartDate: new Date(), + paymentMethod: 'card', + cardToken: body.cardToken, + notes: `Payment successful but subscription creation failed: ${errorText}` + }, + { new: true } + ) + + return { + success: true, + subscription: { + subscriptionId: 'manual-' + Date.now(), + status: 'needs_setup', + error: errorText, + nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + }, + member, + warning: 'Payment successful but recurring subscription needs manual setup' + } + } + + throw createError({ + statusCode: subscriptionResponse.status, + statusMessage: `Failed to create subscription: ${errorText}` + }) + } + + const subscriptionData = await subscriptionResponse.json() + console.log('Subscription created successfully:', subscriptionData) + + // Extract the first subscription from the response array + const subscription = subscriptionData.data?.[0] + if (!subscription) { + throw new Error('No subscription returned in response') + } + + // Update member in database + const member = await Member.findOneAndUpdate( + { helcimCustomerId: body.customerId }, + { + status: 'active', + contributionTier: body.contributionTier, + helcimSubscriptionId: subscription.id, + subscriptionStartDate: new Date(), + paymentMethod: 'card' + }, + { new: true } + ) + + return { + success: true, + subscription: { + subscriptionId: subscription.id, + status: subscription.status, + nextBillingDate: subscription.nextBillingDate + }, + member + } + } catch (fetchError) { + console.error('Error during subscription creation:', fetchError) + + // Still mark member as active since payment was successful + const member = await Member.findOneAndUpdate( + { helcimCustomerId: body.customerId }, + { + status: 'active', + contributionTier: body.contributionTier, + subscriptionStartDate: new Date(), + paymentMethod: 'card', + cardToken: body.cardToken, + notes: `Payment successful but subscription creation failed: ${fetchError.message}` + }, + { new: true } + ) + + return { + success: true, + subscription: { + subscriptionId: 'manual-' + Date.now(), + status: 'needs_setup', + error: fetchError.message, + nextBillingDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) + }, + member, + warning: 'Payment successful but recurring subscription needs manual setup' + } + } + } catch (error) { + console.error('Error creating Helcim subscription:', error) + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.message || 'Failed to create subscription' + }) + } +}) \ No newline at end of file diff --git a/server/api/helcim/subscriptions.get.js b/server/api/helcim/subscriptions.get.js new file mode 100644 index 0000000..f103ed3 --- /dev/null +++ b/server/api/helcim/subscriptions.get.js @@ -0,0 +1,45 @@ +// Get existing Helcim subscriptions to understand the format +const HELCIM_API_BASE = 'https://api.helcim.com/v2' + +export default defineEventHandler(async (event) => { + try { + const config = useRuntimeConfig(event) + const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN + + console.log('Fetching existing subscriptions from Helcim...') + + const response = await fetch(`${HELCIM_API_BASE}/subscriptions`, { + method: 'GET', + headers: { + 'accept': 'application/json', + 'api-token': helcimToken + } + }) + + if (!response.ok) { + console.error('Failed to fetch subscriptions:', response.status, response.statusText) + const errorText = await response.text() + console.error('Response body:', errorText) + + throw createError({ + statusCode: response.status, + statusMessage: `Failed to fetch subscriptions: ${errorText}` + }) + } + + const subscriptionsData = await response.json() + console.log('Existing subscriptions:', JSON.stringify(subscriptionsData, null, 2)) + + return { + success: true, + subscriptions: subscriptionsData + } + + } catch (error) { + console.error('Error fetching Helcim subscriptions:', error) + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.message || 'Failed to fetch subscriptions' + }) + } +}) \ No newline at end of file diff --git a/server/api/helcim/test-connection.get.js b/server/api/helcim/test-connection.get.js new file mode 100644 index 0000000..c4ad093 --- /dev/null +++ b/server/api/helcim/test-connection.get.js @@ -0,0 +1,46 @@ +// Test Helcim API connection +const HELCIM_API_BASE = 'https://api.helcim.com/v2' + +export default defineEventHandler(async (event) => { + try { + const config = useRuntimeConfig(event) + + // Log token info (safely) + const tokenInfo = { + hasToken: !!config.public.helcimToken, + tokenLength: config.public.helcimToken ? config.public.helcimToken.length : 0, + tokenPrefix: config.public.helcimToken ? config.public.helcimToken.substring(0, 10) : null + } + + console.log('Helcim Token Info:', tokenInfo) + + const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN + + // Try connection test endpoint + const response = await $fetch(`${HELCIM_API_BASE}/connection-test`, { + method: 'GET', + headers: { + 'accept': 'application/json', + 'api-token': helcimToken + } + }) + + return { + success: true, + message: 'Helcim API connection successful', + tokenInfo, + connectionResponse: response + } + } catch (error) { + console.error('Helcim test error:', error) + return { + success: false, + message: error.message || 'Failed to connect to Helcim API', + statusCode: error.statusCode, + tokenInfo: { + hasToken: !!useRuntimeConfig().public.helcimToken, + tokenLength: useRuntimeConfig().public.helcimToken ? useRuntimeConfig().public.helcimToken.length : 0 + } + } + } +}) \ No newline at end of file diff --git a/server/api/helcim/test-subscription.get.js b/server/api/helcim/test-subscription.get.js new file mode 100644 index 0000000..ff8b5e6 --- /dev/null +++ b/server/api/helcim/test-subscription.get.js @@ -0,0 +1,77 @@ +// Test minimal subscription creation to understand required fields +const HELCIM_API_BASE = 'https://api.helcim.com/v2' + +export default defineEventHandler(async (event) => { + try { + const config = useRuntimeConfig(event) + const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN + + // Generate a 25-character idempotency key + const idempotencyKey = `${Date.now()}-${Math.random().toString(36).substring(2, 15)}`.substring(0, 25) + + // Test with minimal fields first + const testRequest1 = { + customerCode: 'CST1020', // Use a recent customer code + planId: 20162 + } + + console.log('Testing subscription with minimal fields:', testRequest1) + + try { + const response1 = await fetch(`${HELCIM_API_BASE}/subscriptions`, { + method: 'POST', + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + 'api-token': helcimToken, + 'idempotency-key': idempotencyKey + 'a' + }, + body: JSON.stringify(testRequest1) + }) + + const result1 = await response1.text() + console.log('Test 1 - Status:', response1.status) + console.log('Test 1 - Response:', result1) + + if (!response1.ok) { + // Try with paymentPlanId instead + const testRequest2 = { + customerCode: 'CST1020', + paymentPlanId: 20162 + } + + console.log('Testing subscription with paymentPlanId:', testRequest2) + + const response2 = await fetch(`${HELCIM_API_BASE}/subscriptions`, { + method: 'POST', + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + 'api-token': helcimToken, + 'idempotency-key': idempotencyKey + 'b' + }, + body: JSON.stringify(testRequest2) + }) + + const result2 = await response2.text() + console.log('Test 2 - Status:', response2.status) + console.log('Test 2 - Response:', result2) + } + + } catch (error) { + console.error('Test error:', error) + } + + return { + success: true, + message: 'Check server logs for test results' + } + + } catch (error) { + console.error('Error in test endpoint:', error) + throw createError({ + statusCode: 500, + statusMessage: error.message + }) + } +}) \ No newline at end of file diff --git a/server/api/helcim/update-billing.post.js b/server/api/helcim/update-billing.post.js new file mode 100644 index 0000000..0c1a367 --- /dev/null +++ b/server/api/helcim/update-billing.post.js @@ -0,0 +1,71 @@ +// Update customer billing address +const HELCIM_API_BASE = 'https://api.helcim.com/v2' + +export default defineEventHandler(async (event) => { + try { + const config = useRuntimeConfig(event) + const body = await readBody(event) + + // Validate required fields + if (!body.customerId || !body.billingAddress) { + throw createError({ + statusCode: 400, + statusMessage: 'Customer ID and billing address are required' + }) + } + + const { billingAddress } = body + + // Validate billing address fields + if (!billingAddress.street || !billingAddress.city || !billingAddress.country || !billingAddress.postalCode) { + throw createError({ + statusCode: 400, + statusMessage: 'Complete billing address is required' + }) + } + + const helcimToken = config.public.helcimToken || process.env.NUXT_PUBLIC_HELCIM_TOKEN + + // Update customer billing address in Helcim + const response = await fetch(`${HELCIM_API_BASE}/customers/${body.customerId}`, { + method: 'PATCH', + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + 'api-token': helcimToken + }, + body: JSON.stringify({ + billingAddress: { + name: billingAddress.name, + street1: billingAddress.street, + city: billingAddress.city, + province: billingAddress.province || billingAddress.state, + country: billingAddress.country, + postalCode: billingAddress.postalCode + } + }) + }) + + if (!response.ok) { + const errorText = await response.text() + console.error('Billing address update failed:', response.status, errorText) + throw createError({ + statusCode: response.status, + statusMessage: `Failed to update billing address: ${errorText}` + }) + } + + const customerData = await response.json() + + return { + success: true, + customer: customerData + } + } catch (error) { + console.error('Error updating billing address:', error) + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.message || 'Failed to update billing address' + }) + } +}) \ No newline at end of file diff --git a/server/api/helcim/verify-payment.post.js b/server/api/helcim/verify-payment.post.js new file mode 100644 index 0000000..d975ee4 --- /dev/null +++ b/server/api/helcim/verify-payment.post.js @@ -0,0 +1,38 @@ +// Verify payment token from HelcimPay.js +const HELCIM_API_BASE = 'https://api.helcim.com/v2' + +export default defineEventHandler(async (event) => { + try { + const config = useRuntimeConfig(event) + const body = await readBody(event) + + // Validate required fields + if (!body.cardToken || !body.customerId) { + throw createError({ + statusCode: 400, + statusMessage: 'Card token and customer ID are required' + }) + } + + console.log('Payment verification request:', { + customerId: body.customerId, + cardToken: body.cardToken ? 'present' : 'missing' + }) + + // Since HelcimPay.js already verified the payment and we have the card token, + // we can just return success. The card is already associated with the customer. + console.log('Payment already verified through HelcimPay.js, returning success') + + return { + success: true, + cardToken: body.cardToken, + message: 'Payment verified successfully through HelcimPay.js' + } + } catch (error) { + console.error('Error verifying payment:', error) + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.message || 'Failed to verify payment' + }) + } +}) \ No newline at end of file diff --git a/server/config/contributions.js b/server/config/contributions.js new file mode 100644 index 0000000..d621e81 --- /dev/null +++ b/server/config/contributions.js @@ -0,0 +1,111 @@ +// Server-side contribution config +// Copy of the client-side config for server use + +// Central configuration for Ghost Guild Contribution Levels and Helcim Plans +export const CONTRIBUTION_TIERS = { + FREE: { + value: '0', + amount: 0, + label: '$0 - I need support right now', + tier: 'free', + helcimPlanId: null, // No Helcim plan needed for free tier + features: [ + 'Access to basic resources', + 'Community forum access' + ] + }, + SUPPORTER: { + value: '5', + amount: 5, + label: '$5 - I can contribute a little', + tier: 'supporter', + helcimPlanId: 20162, + features: [ + 'All Free Membership benefits', + 'Priority community support', + 'Early access to events' + ] + }, + MEMBER: { + value: '15', + amount: 15, + label: '$15 - I can sustain the community', + tier: 'member', + helcimPlanId: null, // TODO: Create $15/month plan in Helcim dashboard + features: [ + 'All Supporter benefits', + 'Access to premium workshops', + 'Monthly 1-on-1 sessions', + 'Advanced resource library' + ] + }, + ADVOCATE: { + value: '30', + amount: 30, + label: '$30 - I can support others too', + tier: 'advocate', + helcimPlanId: null, // TODO: Create $30/month plan in Helcim dashboard + features: [ + 'All Member benefits', + 'Weekly group mentoring', + 'Access to exclusive events', + 'Direct messaging with experts' + ] + }, + CHAMPION: { + value: '50', + amount: 50, + label: '$50 - I want to sponsor multiple members', + tier: 'champion', + helcimPlanId: null, // TODO: Create $50/month plan in Helcim dashboard + features: [ + 'All Advocate benefits', + 'Personal mentoring sessions', + 'VIP event access', + 'Custom project support', + 'Annual strategy session' + ] + } +}; + +// Get all contribution options as an array (useful for forms) +export const getContributionOptions = () => { + return Object.values(CONTRIBUTION_TIERS); +}; + +// Get valid contribution values for validation +export const getValidContributionValues = () => { + return Object.values(CONTRIBUTION_TIERS).map(tier => tier.value); +}; + +// Get contribution tier by value +export const getContributionTierByValue = (value) => { + return Object.values(CONTRIBUTION_TIERS).find(tier => tier.value === value); +}; + +// Get Helcim plan ID for a contribution tier +export const getHelcimPlanId = (contributionValue) => { + const tier = getContributionTierByValue(contributionValue); + return tier?.helcimPlanId || null; +}; + +// Check if a contribution tier requires payment +export const requiresPayment = (contributionValue) => { + const tier = getContributionTierByValue(contributionValue); + return tier?.amount > 0; +}; + +// Check if a contribution value is valid +export const isValidContributionValue = (value) => { + return getValidContributionValues().includes(value); +}; + +// Get contribution tier by Helcim plan ID +export const getContributionTierByHelcimPlan = (helcimPlanId) => { + return Object.values(CONTRIBUTION_TIERS).find(tier => tier.helcimPlanId === helcimPlanId); +}; + +// Get paid tiers only (excluding free tier) +export const getPaidContributionTiers = () => { + return Object.values(CONTRIBUTION_TIERS).filter(tier => tier.amount > 0); +}; diff --git a/server/models/member.js b/server/models/member.js index 54d51e8..3546717 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -22,8 +22,21 @@ const memberSchema = new mongoose.Schema({ enum: getValidContributionValues(), required: true }, + status: { + type: String, + enum: ['pending_payment', 'active', 'suspended', 'cancelled'], + default: 'pending_payment' + }, helcimCustomerId: String, helcimSubscriptionId: String, + paymentMethod: { + type: String, + enum: ['card', 'bank', 'none'], + default: 'none' + }, + subscriptionStartDate: Date, + subscriptionEndDate: Date, + nextBillingDate: Date, slackInvited: { type: Boolean, default: false }, createdAt: { type: Date, default: Date.now }, lastLogin: Date diff --git a/test-helcim-direct.js b/test-helcim-direct.js new file mode 100644 index 0000000..e3054cb --- /dev/null +++ b/test-helcim-direct.js @@ -0,0 +1,49 @@ +// Direct test of Helcim API with your token +// Run with: node test-helcim-direct.js + +const token = process.env.NUXT_PUBLIC_HELCIM_TOKEN || 'aG_Eu%lqXCIJdWb2fUx52P_*-9GzaUHAVXvRjF43#sZw_FEeV9q7gl$pe$1EPRNs' + +async function testHelcimConnection() { + console.log('Testing Helcim API connection...') + console.log('Token length:', token.length) + console.log('Token prefix:', token.substring(0, 10) + '...') + + try { + // Test 1: Try to get connection test + const testResponse = await fetch('https://api.helcim.com/v2/connection-test', { + method: 'GET', + headers: { + 'accept': 'application/json', + 'api-token': token + } + }) + + console.log('Connection test status:', testResponse.status) + const testData = await testResponse.text() + console.log('Connection test response:', testData) + + // Test 2: Try to create a customer + const customerResponse = await fetch('https://api.helcim.com/v2/customers', { + method: 'POST', + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + 'api-token': token + }, + body: JSON.stringify({ + customerType: 'PERSON', + contactName: 'Test User', + email: 'test@example.com' + }) + }) + + console.log('\nCreate customer status:', customerResponse.status) + const customerData = await customerResponse.text() + console.log('Create customer response:', customerData) + + } catch (error) { + console.error('Error:', error) + } +} + +testHelcimConnection() \ No newline at end of file From 600fef2b7c3f93a04579f005ae8ec984e2c6d57c Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 3 Sep 2025 16:55:01 +0100 Subject: [PATCH 3/4] Enhance authentication flow: Add authentication-based buttons in AppNavigation for logged-in users, improve member status checks in useAuth, and update join page to automatically redirect to the dashboard after registration. Adjust cookie settings for better development experience. --- app/components/AppNavigation.vue | 40 +++++++ app/composables/useAuth.js | 26 +++-- app/middleware/auth.js | 27 +++++ app/pages/join.vue | 31 +++++- app/pages/member/dashboard.vue | 161 +++++++++++++++++++++++++++++ app/plugins/auth-init.client.js | 20 ++++ server/api/auth/logout.post.js | 6 +- server/api/auth/member.get.js | 2 + server/api/auth/status.get.js | 40 +++++++ server/api/auth/verify.get.js | 4 +- server/api/helcim/customer.post.js | 15 ++- 11 files changed, 347 insertions(+), 25 deletions(-) create mode 100644 app/middleware/auth.js create mode 100644 app/pages/member/dashboard.vue create mode 100644 app/plugins/auth-init.client.js create mode 100644 server/api/auth/status.get.js diff --git a/app/components/AppNavigation.vue b/app/components/AppNavigation.vue index 803f8bd..5bbeb7e 100644 --- a/app/components/AppNavigation.vue +++ b/app/components/AppNavigation.vue @@ -22,7 +22,25 @@ > {{ item.label }} + +
+ + Dashboard + + + Logout + +
{{ item.label }} + +
+ + Dashboard + + + Logout + +
{ - const authCookie = useCookie('auth-token') const memberData = useState('auth.member', () => null) - const isAuthenticated = computed(() => !!authCookie.value) + + const isAuthenticated = computed(() => !!memberData.value) + const isMember = computed(() => !!memberData.value) const checkMemberStatus = async () => { - if (!authCookie.value) { - memberData.value = null - return false - } - + console.log('🔍 checkMemberStatus called') + console.log(' - Current memberData:', !!memberData.value) + try { - const response = await $fetch('/api/auth/member', { - headers: { - 'Cookie': `auth-token=${authCookie.value}` - } - }) + console.log(' - Making API call to /api/auth/member...') + const response = await $fetch('/api/auth/member') + console.log(' - API response received:', { email: response.email, id: response.id }) memberData.value = response + console.log(' - ✅ Member authenticated successfully') return true } catch (error) { - console.error('Failed to fetch member status:', error) + console.error(' - ❌ Failed to fetch member status:', error.statusCode, error.statusMessage) memberData.value = null + console.log(' - Cleared memberData') return false } } @@ -30,7 +29,6 @@ export const useAuth = () => { await $fetch('/api/auth/logout', { method: 'POST' }) - authCookie.value = null memberData.value = null await navigateTo('/') } catch (error) { diff --git a/app/middleware/auth.js b/app/middleware/auth.js new file mode 100644 index 0000000..a29566b --- /dev/null +++ b/app/middleware/auth.js @@ -0,0 +1,27 @@ +export default defineNuxtRouteMiddleware(async (to, from) => { + // Skip on server-side rendering + if (process.server) { + console.log('🛡️ Auth middleware - skipping on server') + return + } + + const { memberData, checkMemberStatus } = useAuth() + + console.log('🛡️ Auth middleware (CLIENT) - route:', to.path) + console.log(' - memberData exists:', !!memberData.value) + console.log(' - Running on:', process.server ? 'SERVER' : 'CLIENT') + + // If no member data, try to check authentication + if (!memberData.value) { + console.log(' - No member data, checking authentication...') + const isAuthenticated = await checkMemberStatus() + console.log(' - Authentication result:', isAuthenticated) + + if (!isAuthenticated) { + console.log(' - ❌ Authentication failed, redirecting to login') + return navigateTo('/login') + } + } + + console.log(' - ✅ Authentication successful for:', memberData.value?.email) +}) \ No newline at end of file diff --git a/app/pages/join.vue b/app/pages/join.vue index 60d865f..3e530d4 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -277,17 +277,23 @@
-

+

We've sent a confirmation email to {{ form.email }} with your membership details.

+
+

+ You will be automatically redirected to your dashboard in a few seconds... +

+
+
- Go to Dashboard + Go to Dashboard Now { @@ -577,9 +584,8 @@ const handleSubmit = async () => { customerId.value = response.customerId customerCode.value = response.customerCode - // Store token in session - const authToken = useCookie('auth-token') - authToken.value = response.token + // Token is now set as httpOnly cookie by the server + // No need to manually set cookie on client side // Move to next step if (needsPayment.value) { @@ -591,6 +597,13 @@ const handleSubmit = async () => { } else { // For free tier, create subscription directly await createSubscription() + // Check member status to ensure user is properly authenticated + await checkMemberStatus() + + // Automatically redirect to dashboard after a short delay + setTimeout(() => { + navigateTo('/member/dashboard') + }, 3000) // 3 second delay to show success message } } } catch (error) { @@ -681,6 +694,14 @@ const createSubscription = async (cardToken = null) => { console.log('Moving to step 3 - success!') currentStep.value = 3 successMessage.value = 'Your membership has been activated successfully!' + + // Check member status to ensure user is properly authenticated + await checkMemberStatus() + + // Automatically redirect to dashboard after a short delay + setTimeout(() => { + navigateTo('/member/dashboard') + }, 3000) // 3 second delay to show success message } else { throw new Error('Subscription creation failed - response not successful') } diff --git a/app/pages/member/dashboard.vue b/app/pages/member/dashboard.vue new file mode 100644 index 0000000..70e53ff --- /dev/null +++ b/app/pages/member/dashboard.vue @@ -0,0 +1,161 @@ + + + \ No newline at end of file diff --git a/app/plugins/auth-init.client.js b/app/plugins/auth-init.client.js new file mode 100644 index 0000000..44a3e21 --- /dev/null +++ b/app/plugins/auth-init.client.js @@ -0,0 +1,20 @@ +export default defineNuxtPlugin(async () => { + const { memberData, checkMemberStatus } = useAuth() + + console.log('🚀 Auth init plugin running on CLIENT') + + // Only initialize if we don't already have member data + if (!memberData.value) { + console.log(' - No member data, checking auth status...') + + const isAuthenticated = await checkMemberStatus() + + if (isAuthenticated) { + console.log(' - ✅ Authentication successful') + } else { + console.log(' - ❌ No valid authentication') + } + } else { + console.log(' - ✅ Member data already exists:', memberData.value.email) + } +}) \ No newline at end of file diff --git a/server/api/auth/logout.post.js b/server/api/auth/logout.post.js index 87f6065..56c7fdf 100644 --- a/server/api/auth/logout.post.js +++ b/server/api/auth/logout.post.js @@ -1,9 +1,9 @@ export default defineEventHandler(async (event) => { // Clear the auth token cookie setCookie(event, 'auth-token', '', { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'strict', + httpOnly: false, // Match the original cookie settings + secure: false, // Don't require HTTPS in development + sameSite: 'lax', maxAge: 0 // Expire immediately }) diff --git a/server/api/auth/member.get.js b/server/api/auth/member.get.js index 259dcc9..6a7d62f 100644 --- a/server/api/auth/member.get.js +++ b/server/api/auth/member.get.js @@ -6,8 +6,10 @@ export default defineEventHandler(async (event) => { await connectDB() const token = getCookie(event, 'auth-token') + console.log('Auth check - token found:', !!token) if (!token) { + console.log('No auth token found in cookies') throw createError({ statusCode: 401, statusMessage: 'Not authenticated' diff --git a/server/api/auth/status.get.js b/server/api/auth/status.get.js new file mode 100644 index 0000000..9a7d2da --- /dev/null +++ b/server/api/auth/status.get.js @@ -0,0 +1,40 @@ +import jwt from 'jsonwebtoken' +import Member from '../../models/member.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + await connectDB() + + const token = getCookie(event, 'auth-token') + console.log('🔍 Auth status check - token exists:', !!token) + + if (!token) { + return { authenticated: false, member: null } + } + + try { + const decoded = jwt.verify(token, process.env.JWT_SECRET) + const member = await Member.findById(decoded.memberId).select('-__v') + + if (!member) { + console.log('⚠️ Token valid but member not found') + return { authenticated: false, member: null } + } + + console.log('✅ Auth status check - member found:', member.email) + return { + authenticated: true, + member: { + id: member._id, + email: member.email, + name: member.name, + circle: member.circle, + contributionTier: member.contributionTier, + membershipLevel: `${member.circle}-${member.contributionTier}` + } + } + } catch (err) { + console.error('❌ Auth status check - token verification failed:', err.message) + return { authenticated: false, member: null } + } +}) \ No newline at end of file diff --git a/server/api/auth/verify.get.js b/server/api/auth/verify.get.js index b2eee10..31a66f2 100644 --- a/server/api/auth/verify.get.js +++ b/server/api/auth/verify.get.js @@ -38,8 +38,8 @@ export default defineEventHandler(async (event) => { // Set the session cookie setCookie(event, 'auth-token', sessionToken, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', + httpOnly: false, // Allow JavaScript access for debugging in development + secure: false, // Don't require HTTPS in development sameSite: 'lax', maxAge: 60 * 60 * 24 * 30 // 30 days }) diff --git a/server/api/helcim/customer.post.js b/server/api/helcim/customer.post.js index d7e3454..782a0da 100644 --- a/server/api/helcim/customer.post.js +++ b/server/api/helcim/customer.post.js @@ -108,10 +108,23 @@ export default defineEventHandler(async (event) => { email: body.email, helcimCustomerId: customerData.id }, - config.jwtSecret, + process.env.JWT_SECRET, { expiresIn: '24h' } ) + // Set the session cookie server-side + console.log('Setting auth-token cookie for member:', member.email) + console.log('NODE_ENV:', process.env.NODE_ENV) + setCookie(event, 'auth-token', token, { + httpOnly: true, // Server-only for security + secure: false, // Don't require HTTPS in development + sameSite: 'lax', + maxAge: 60 * 60 * 24, // 24 hours + path: '/', + domain: undefined // Let browser set domain automatically + }) + console.log('Cookie set successfully') + return { success: true, customerId: customerData.id, From 2b55ca4104a8587cf468a90cad0a31de7c29496f Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Sun, 5 Oct 2025 16:15:09 +0100 Subject: [PATCH 4/4] Adding features --- CLAUDE.md | 329 +++++ app/app.config.ts | 9 +- app/assets/css/main.css | 134 +- app/components/AppFooter.vue | 301 +--- app/components/AppNavigation.vue | 301 ++-- app/components/PageHeader.vue | 192 ++- app/components/PrivacyToggle.vue | 47 + app/components/UpdateCard.vue | 189 +++ app/components/UpdateForm.vue | 182 +++ app/layouts/default.vue | 38 +- app/pages/about.vue | 482 +++---- app/pages/contact.vue | 424 ++---- app/pages/events/[id].vue | 645 ++++++--- app/pages/events/index.vue | 775 +++++----- app/pages/index.vue | 190 +-- app/pages/join.vue | 764 +++++----- app/pages/member/dashboard.vue | 704 +++++++-- app/pages/member/my-updates.vue | 211 +++ app/pages/member/profile.vue | 1273 +++++++++++++++++ app/pages/members.vue | 400 ++++++ app/pages/members/index.vue | 68 - app/pages/updates/[id]/edit.vue | 135 ++ app/pages/updates/[id]/index.vue | 153 ++ app/pages/updates/index.vue | 198 +++ app/pages/updates/new.vue | 84 ++ app/pages/updates/user/[id].vue | 193 +++ nuxt.config.ts | 8 + package-lock.json | 1083 ++++++++++---- package.json | 3 +- public/background-dither.png | Bin 0 -> 1083166 bytes public/favicon.ico | Bin 4286 -> 0 bytes public/ghosties/Ghost-Disbelieving.png | Bin 0 -> 3617 bytes public/ghosties/Ghost-Disbelieving.svg | 17 + public/ghosties/Ghost-Double-Take.png | Bin 0 -> 3075 bytes public/ghosties/Ghost-Double-Take.svg | 17 + public/ghosties/Ghost-Exasperated.png | Bin 0 -> 2325 bytes public/ghosties/Ghost-Exasperated.svg | 17 + public/ghosties/Ghost-Mild.png | Bin 0 -> 2609 bytes public/ghosties/Ghost-Mild.svg | 17 + public/ghosties/Ghost-Sweet.png | Bin 0 -> 2917 bytes public/ghosties/Ghost-Sweet.svg | 17 + public/ghosties/Ghost-WTF.png | Bin 0 -> 3917 bytes public/ghosties/Ghost-WTF.svg | 17 + scripts/fix-avatars.js | 58 + scripts/setup-helcim-plans.js | 121 ++ server/api/auth/login.post.js | 89 +- server/api/auth/member.get.js | 74 +- server/api/contributions/options.get.js | 19 + .../events/[id]/cancel-registration.post.js | 69 + .../events/[id]/check-registration.post.js | 49 + server/api/events/[id]/register.post.js | 141 +- server/api/helcim/create-plan.post.js | 61 + server/api/helcim/customer-code.get.js | 83 ++ .../api/helcim/get-or-create-customer.post.js | 130 ++ server/api/helcim/subscription.post.js | 80 ++ .../api/members/cancel-subscription.post.js | 100 ++ server/api/members/create.post.js | 68 + server/api/members/directory.get.js | 115 ++ server/api/members/my-events.get.js | 60 + server/api/members/profile.patch.js | 117 ++ .../api/members/update-contribution.post.js | 354 +++++ server/api/slack/test-bot.get.ts | 68 + server/api/updates/[id].delete.js | 59 + server/api/updates/[id].get.js | 60 + server/api/updates/[id].patch.js | 68 + server/api/updates/index.get.js | 56 + server/api/updates/index.post.js | 57 + server/api/updates/my-updates.get.js | 53 + server/api/updates/user/[id].get.js | 76 + server/config/contributions.js | 93 +- server/models/event.js | 134 +- server/models/member.js | 113 +- server/models/update.js | 50 + server/utils/slack.ts | 233 +++ slack-app-manifest.yaml | 30 + 75 files changed, 9796 insertions(+), 2759 deletions(-) create mode 100644 CLAUDE.md create mode 100644 app/components/PrivacyToggle.vue create mode 100644 app/components/UpdateCard.vue create mode 100644 app/components/UpdateForm.vue create mode 100644 app/pages/member/my-updates.vue create mode 100644 app/pages/member/profile.vue create mode 100644 app/pages/members.vue delete mode 100644 app/pages/members/index.vue create mode 100644 app/pages/updates/[id]/edit.vue create mode 100644 app/pages/updates/[id]/index.vue create mode 100644 app/pages/updates/index.vue create mode 100644 app/pages/updates/new.vue create mode 100644 app/pages/updates/user/[id].vue create mode 100644 public/background-dither.png delete mode 100644 public/favicon.ico create mode 100644 public/ghosties/Ghost-Disbelieving.png create mode 100644 public/ghosties/Ghost-Disbelieving.svg create mode 100644 public/ghosties/Ghost-Double-Take.png create mode 100644 public/ghosties/Ghost-Double-Take.svg create mode 100644 public/ghosties/Ghost-Exasperated.png create mode 100644 public/ghosties/Ghost-Exasperated.svg create mode 100644 public/ghosties/Ghost-Mild.png create mode 100644 public/ghosties/Ghost-Mild.svg create mode 100644 public/ghosties/Ghost-Sweet.png create mode 100644 public/ghosties/Ghost-Sweet.svg create mode 100644 public/ghosties/Ghost-WTF.png create mode 100644 public/ghosties/Ghost-WTF.svg create mode 100644 scripts/fix-avatars.js create mode 100644 scripts/setup-helcim-plans.js create mode 100644 server/api/contributions/options.get.js create mode 100644 server/api/events/[id]/cancel-registration.post.js create mode 100644 server/api/events/[id]/check-registration.post.js create mode 100644 server/api/helcim/create-plan.post.js create mode 100644 server/api/helcim/customer-code.get.js create mode 100644 server/api/helcim/get-or-create-customer.post.js create mode 100644 server/api/members/cancel-subscription.post.js create mode 100644 server/api/members/directory.get.js create mode 100644 server/api/members/my-events.get.js create mode 100644 server/api/members/profile.patch.js create mode 100644 server/api/members/update-contribution.post.js create mode 100644 server/api/slack/test-bot.get.ts create mode 100644 server/api/updates/[id].delete.js create mode 100644 server/api/updates/[id].get.js create mode 100644 server/api/updates/[id].patch.js create mode 100644 server/api/updates/index.get.js create mode 100644 server/api/updates/index.post.js create mode 100644 server/api/updates/my-updates.get.js create mode 100644 server/api/updates/user/[id].get.js create mode 100644 server/models/update.js create mode 100644 server/utils/slack.ts create mode 100644 slack-app-manifest.yaml diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0abd815 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,329 @@ +## 2. Member Features + +### Member Profiles + +**Core Fields:** + +- Name, pronouns, time zone +- Avatar/photo - choose from ghosts +- Studio/organization affiliation +- Bio (rich text) +- Skills tags (searchable) +- Location (city/region) +- Social links (Mastodon, LinkedIn, etc.) +- **Offering:** What I can contribute +- **Looking For:** What I need support with + +**Privacy Controls:** + +- Public/members-only/private toggle per field +- Opt-in to member directory + +### Member Updates/Mini Blog + +- Post updates about projects, learnings, questions +- Rich text with image support +- Comments enabled +- Filter by circle or topic tags + +## 3. Events System + +### Core Features + +- RSVP with capacity limits +- Waitlist management +- Add to calendar (.ics download) +- Pre-event discussion threads +- Post-event recordings archive +- Speaker/facilitator profiles + +### Member-Proposed Events + +**Proposal Flow:** + +1. Member submits event idea via form +2. Include: Topic, format, target circle, time commitment +3. Admin quick review (spam check only) +4. Published to "Proposed Events" board +5. Members can express interest (like feature upvote pages) +6. If threshold met (e.g., 5 interested), event is scheduled +7. Proposer gets facilitator support if needed + +## 4. Resources Integration + +### Consolidating Existing Assets + +**Import and organize from:** + +- learn.weirdghosts.ca content +- Existing tools and templates +- PA curriculum materials (where appropriate) +- Case studies and examples + +**Organization Structure:** + +``` +Resources/ +├── Start Here/ +│ ├── Welcome Letter from Jennie & Eileen +│ ├── How Ghost Guild Works +│ └── Solidarity Economics Explained +├── Learning Paths/ +│ ├── Community Track → links to learn.weirdghosts.ca +│ ├── Founder Track → practical tools +│ └── Practitioner Track → advanced resources +├── Templates & Tools/ +│ ├── Governance/ +│ ├── Financial/ +│ ├── Operations/ +│ └── Legal/ +├── Case Studies/ +│ └── Member stories and examples +└── External Resources/ + └── Curated links and recommendations +``` + +### Resource Features + +- Tag by circle relevance (but accessible to all) +- Download tracking for impact metrics +- Version control for templates +- Comment threads on resources +- "Request a resource" feature + +## 5. Peer Support System + +### Cal.com Integration for 1:1s + +**Setup:** + +- Each member can enable peer support availability +- Set their own hours/frequency +- Cal.com handles scheduling +- Types of sessions: + - Peer support (30 min) + - Co-founder check-in (45 min) + - Practitioner office hours (60 min) + +**Matching System:** + +- Simple questionnaire about current needs +- Suggest 3 potential peers based on: + - Complementary skills/needs + - Time zone compatibility + - Circle alignment (optional) +- Book directly via Cal.com links + +## 6. Dashboard Design + +### Personalized Sections + +**Welcome Block:** + +- "Welcome back, [Name]" +- Your circle: [Circle] | Your contribution: $X/month +- Quick stats: Days as member, events attended, peers met + +**Community Pulse:** + +- Recent member updates (mini blog posts) +- Upcoming events this week +- New resources added +- New members to welcome + +**Your Activity:** + +- Your upcoming events +- Scheduled peer sessions +- Recent discussions you're in +- Resources you've bookmarked + +**Take Action:** + +- Post an update +- Propose an event +- Book a peer session +- Browse resources +- Update profile + +**Impact Metrics:** + +- Total solidarity spots funded +- Events hosted this month +- Active members this week +- Resources shared + +## 7. Collaborative Tools + +### Etherpad Integration + +**Use Cases:** + +- Meeting notes templates +- Collaborative resource creation +- Event planning documents +- Shared learning notes + +**Implementation:** + +- Self-hosted Etherpad instance +- SSO with Ghost Guild accounts +- Auto-save and version history +- Export to multiple formats +- Embed in event pages for notes + +### Living Documents + +- Community-maintained guides +- Glossaries and definitions +- Frequently asked questions +- Best practices collections + +## 8. Technical Infrastructure + +### Notification System + +**Channels:** + +- Email (via Resend) +- In-app notifications +- Slack integration via bot + +**Configurable Preferences:** + +- Event reminders +- New resources in your area +- Peer session invitations +- Member updates digest +- Community announcements + +### Search & Discovery + +- Full-text search across: + - Resources + - Member profiles + - Event descriptions + - Member updates +- Filter by circle, tags, date +- Save searches for alerts + +### Analytics & Reporting + +- Member engagement metrics +- Resource usage stats +- Event attendance patterns +- Contribution distribution +- Circle movement tracking + +## 9. Content for Launch + +### Essential Content Pieces + +1. **Welcome Video** - Jennie & Eileen introduce Ghost Guild +2. **How This Works** - Clear explanation of circles and contributions +3. **Circle Guides** - What to expect in each circle +4. **Solidarity Economics** - Practical examples from gaming +5. **Getting Started Checklist** - First week actions + +### Pre-Populated Content + +- 10-15 essential resources per circle +- 3-5 upcoming events scheduled +- Sample member updates to show activity +- FAQ based on pre-registration questions + +## 10. Launch Strategy + +### Soft Launch (Week Before) + +- Invite 10-15 friendly testers +- Each from different backgrounds/circles +- Gather feedback on: + - Onboarding flow + - Resource organization + - Event system + - Profile creation + +### Launch Week + +**Day 1-2:** PA alumni and close network + +- Personal invitations +- Extra support available +- Gather testimonials + +**Day 3-4:** Gamma Space announcement + +- Post in relevant channels +- Host info session + +**Day 5-7:** Public launch + +- Email pre-registration list +- Social media announcement +- Open registration + +### Success Metrics + +**Week 1:** + +- 30 members across all circles +- 80% complete profiles +- 50% attend first event + +**Month 1:** + +- 75 active members +- 5 member-proposed events +- 20 peer sessions booked +- 90% Slack participation + +## 11. Ongoing Operations + +### Weekly Tasks + +- Review member proposals for events +- Process Gamma Space channel access +- Update resource library +- Send member spotlight + +### Monthly Tasks + +- Impact report to members +- Review and adjust contribution distribution +- Plan next month's events +- Gather member feedback + +### Quarterly Reviews + +- Assess circle definitions +- Evaluate pricing model +- Review platform features +- Plan new initiatives + +--- + +## Implementation Priority Order + +### Must Have for Launch + +1. Payment processing (Helcim) +2. Basic Slack automation +3. Member dashboard +4. Simple resource library +5. Event listing and RSVP + +### Nice to Have for Launch + +7. Member profiles +8. Peer matching system +9. Cal.com integration +10. Member updates/blog + +### Can Build Post-Launch + +11. Etherpad integration +12. Member-proposed events +13. Advanced search +14. Analytics dashboard +15. Monthly themes diff --git a/app/app.config.ts b/app/app.config.ts index 51f28f0..363e07f 100644 --- a/app/app.config.ts +++ b/app/app.config.ts @@ -1,8 +1,13 @@ export default defineAppConfig({ ui: { colors: { - primary: "pink", - neutral: "zinc", + primary: "emerald", + neutral: "stone", + }, + formField: { + slots: { + label: "block font-medium text-stone-200", + }, }, }, }); diff --git a/app/assets/css/main.css b/app/assets/css/main.css index a5e24e1..22cb675 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -2,13 +2,145 @@ @import "tailwindcss"; @import "@nuxt/ui"; -@theme { +@theme static { /* Font families */ --font-sans: "Inter", sans-serif; --font-body: "Inter", sans-serif; --font-mono: "Ubuntu Mono", monospace; --font-display: "NB Television Pro", monospace; + /* Ethereal color palette - grays, blacks, minimal color */ + --color-ghost-50: #f0f0f0; + --color-ghost-100: #d0d0d0; + --color-ghost-200: #b0b0b0; + --color-ghost-300: #8a8a8a; + --color-ghost-400: #6a6a6a; + --color-ghost-500: #4a4a4a; + --color-ghost-600: #3a3a3a; + --color-ghost-700: #2a2a2a; + --color-ghost-800: #1a1a1a; + --color-ghost-900: #0a0a0a; + + /* Subtle accent - barely visible blue-gray */ + --color-whisper-50: #d4dae6; + --color-whisper-100: #a8b3c7; + --color-whisper-200: #8491a8; + --color-whisper-300: #687291; + --color-whisper-400: #4f5d7a; + --color-whisper-500: #3a4964; + --color-whisper-600: #2f3b52; + --color-whisper-700: #252d40; + --color-whisper-800: #1a1f2e; + --color-whisper-900: #0f1419; + /* Sparkle accent */ + --color-sparkle-50: #fafafa; + --color-sparkle-100: #f0f0f0; + --color-sparkle-200: #e8e8e8; + --color-sparkle-300: #d0d0d0; + --color-sparkle-400: #c0c0c0; + --color-sparkle-500: #a0a0a0; + --color-sparkle-600: #808080; + --color-sparkle-700: #606060; + --color-sparkle-800: #404040; + --color-sparkle-900: #202020; +} + +/* Global ethereal background */ +:root { + --ethereal-bg: radial-gradient(circle at 20% 80%, rgba(232, 232, 232, 0.03) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(232, 232, 232, 0.02) 0%, transparent 50%), + radial-gradient(circle at 40% 40%, rgba(232, 232, 232, 0.01) 0%, transparent 50%); + + --halftone-pattern: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px); + --halftone-size: 8px 8px; +} + +html { + background: var(--color-ghost-900); + color: var(--color-ghost-200); +} + +body { + background: var(--ethereal-bg), var(--color-ghost-900); + background-attachment: fixed; +} + +/* Halftone texture overlay */ +.halftone-texture { + position: relative; +} + +.halftone-texture::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: var(--halftone-pattern); + background-size: var(--halftone-size); + opacity: 0.1; + pointer-events: none; +} + +/* Sparkle effects */ +@keyframes sparkle { + 0%, 100% { opacity: 0.3; transform: scale(0.8); } + 50% { opacity: 1; transform: scale(1.2); } +} + +@keyframes twinkle { + 0%, 100% { opacity: 0.2; } + 25% { opacity: 0.8; } + 75% { opacity: 0.4; } +} + +.sparkle-field { + position: relative; + overflow: hidden; +} + +.sparkle-field::after { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + radial-gradient(circle at 10% 20%, var(--color-sparkle-200) 1px, transparent 1px), + radial-gradient(circle at 90% 80%, var(--color-sparkle-400) 1px, transparent 1px), + radial-gradient(circle at 30% 70%, var(--color-sparkle-200) 0.5px, transparent 0.5px), + radial-gradient(circle at 70% 30%, var(--color-sparkle-400) 0.5px, transparent 0.5px), + radial-gradient(circle at 50% 10%, var(--color-sparkle-200) 1px, transparent 1px), + radial-gradient(circle at 20% 90%, var(--color-sparkle-400) 0.5px, transparent 0.5px); + background-size: 200px 200px, 300px 300px, 150px 150px, 250px 250px, 180px 180px, 220px 220px; + animation: twinkle 4s infinite ease-in-out; + pointer-events: none; + opacity: 0.6; +} + +/* Ethereal glow effects */ +.ethereal-glow { + box-shadow: + 0 0 20px rgba(232, 232, 232, 0.1), + 0 0 40px rgba(232, 232, 232, 0.05), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.ethereal-text { + text-shadow: 0 0 10px rgba(232, 232, 232, 0.3); +} + +/* Dithered gradients */ +.dithered-bg { + background: + linear-gradient(45deg, var(--color-ghost-800) 25%, transparent 25%), + linear-gradient(-45deg, var(--color-ghost-800) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, var(--color-ghost-700) 75%), + linear-gradient(-45deg, transparent 75%, var(--color-ghost-700) 75%); + background-size: 4px 4px; + background-position: 0 0, 0 2px, 2px -2px, -2px 0px; } diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue index 74928a8..7f35a63 100644 --- a/app/components/AppFooter.vue +++ b/app/components/AppFooter.vue @@ -1,288 +1,31 @@ \ No newline at end of file +const currentYear = new Date().getFullYear(); + diff --git a/app/components/AppNavigation.vue b/app/components/AppNavigation.vue index 5bbeb7e..f7b1473 100644 --- a/app/components/AppNavigation.vue +++ b/app/components/AppNavigation.vue @@ -1,142 +1,191 @@ + + diff --git a/app/components/PageHeader.vue b/app/components/PageHeader.vue index b756f47..a5646cc 100644 --- a/app/components/PageHeader.vue +++ b/app/components/PageHeader.vue @@ -1,65 +1,108 @@ \ No newline at end of file + return "text-white"; +}); + diff --git a/app/components/PrivacyToggle.vue b/app/components/PrivacyToggle.vue new file mode 100644 index 0000000..8b0ca9a --- /dev/null +++ b/app/components/PrivacyToggle.vue @@ -0,0 +1,47 @@ + + + diff --git a/app/components/UpdateCard.vue b/app/components/UpdateCard.vue new file mode 100644 index 0000000..af76bc1 --- /dev/null +++ b/app/components/UpdateCard.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/app/components/UpdateForm.vue b/app/components/UpdateForm.vue new file mode 100644 index 0000000..286e77b --- /dev/null +++ b/app/components/UpdateForm.vue @@ -0,0 +1,182 @@ + + + + + diff --git a/app/layouts/default.vue b/app/layouts/default.vue index befc6f9..07411f7 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -1,7 +1,35 @@ \ No newline at end of file + diff --git a/app/pages/about.vue b/app/pages/about.vue index 44ddd0e..cbc7469 100644 --- a/app/pages/about.vue +++ b/app/pages/about.vue @@ -1,306 +1,278 @@