From e4a0a9ab0fafa439e3fc6b7d897a9a2906cde77e Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 27 Aug 2025 16:49:51 +0100 Subject: [PATCH 1/2] Enhance application structure: Add runtime configuration for environment variables, integrate new dependencies for Cloudinary and UI components, and refactor member management features including improved forms and member dashboard. Update styles and layout for better user experience. --- .env.example | 23 + README.md | 2 +- app/app.config.ts | 8 + app/app.vue | 4 +- app/assets/css/fonts.css | 2 + app/assets/css/main.css | 800 +----------------------- app/components/AppFooter.vue | 288 +++++++++ app/components/AppNavigation.vue | 102 +++ app/components/ImageUpload.vue | 201 ++++++ app/components/PageHeader.vue | 172 +++++ app/config/circles.js | 54 ++ app/config/contributions.js | 108 ++++ app/layouts/admin.vue | 220 +++++++ app/layouts/default.vue | 7 + app/middleware/admin.js | 18 + app/pages/about.vue | 312 +++++++++ app/pages/admin/dashboard.vue | 250 ++++++++ app/pages/admin/events-working.vue | 361 +++++++++++ app/pages/admin/events/create.vue | 594 ++++++++++++++++++ app/pages/admin/events/index.vue | 336 ++++++++++ app/pages/admin/index-working.vue | 112 ++++ app/pages/admin/index.vue | 250 ++++++++ app/pages/admin/members-simple.vue | 101 +++ app/pages/admin/members-working.vue | 229 +++++++ app/pages/admin/members.vue | 255 +++++++- app/pages/admin/test.vue | 30 + app/pages/contact.vue | 322 ++++++++++ app/pages/events/[id].vue | 417 ++++++++++++ app/pages/events/index.vue | 390 ++++++++++++ app/pages/index.vue | 138 ++-- app/pages/join.vue | 392 ++++++++++-- app/pages/login.vue | 377 +++++++++++ app/pages/members/index.vue | 38 +- nuxt.config.ts | 18 + package-lock.json | 154 ++++- package.json | 8 +- plugins/cloudinary.client.js | 17 + scripts/migrate-event-slugs.js | 66 ++ scripts/seed-all.js | 44 ++ scripts/seed-events.js | 282 +++++++++ scripts/seed-members.js | 199 ++++++ server/api/admin/dashboard.get.js | 70 +++ server/api/admin/events.get.js | 34 + server/api/admin/events.post.js | 50 ++ server/api/admin/events/[id].delete.js | 41 ++ server/api/admin/events/[id].get.js | 47 ++ server/api/admin/events/[id].put.js | 62 ++ server/api/admin/members.get.js | 34 + server/api/admin/members.post.js | 60 ++ server/api/auth/login.post.js | 74 ++- server/api/auth/logout.post.js | 11 + server/api/auth/verify.get.js | 57 ++ server/api/events/[id].get.js | 65 ++ server/api/events/[id]/register.post.js | 116 ++++ server/api/events/index.get.js | 53 ++ server/api/members/create.post.js | 30 +- server/api/upload/image.post.js | 72 +++ server/models/event.js | 99 +++ server/models/member.js | 12 +- server/utils/helcim.js | 140 +++++ server/utils/mongoose.js | 24 + 61 files changed, 7902 insertions(+), 950 deletions(-) create mode 100644 .env.example create mode 100644 app/app.config.ts create mode 100644 app/assets/css/fonts.css create mode 100644 app/components/AppFooter.vue create mode 100644 app/components/AppNavigation.vue create mode 100644 app/components/ImageUpload.vue create mode 100644 app/components/PageHeader.vue create mode 100644 app/config/circles.js create mode 100644 app/config/contributions.js create mode 100644 app/layouts/admin.vue create mode 100644 app/layouts/default.vue create mode 100644 app/middleware/admin.js create mode 100644 app/pages/about.vue create mode 100644 app/pages/admin/dashboard.vue create mode 100644 app/pages/admin/events-working.vue create mode 100644 app/pages/admin/events/create.vue create mode 100644 app/pages/admin/events/index.vue create mode 100644 app/pages/admin/index-working.vue create mode 100644 app/pages/admin/index.vue create mode 100644 app/pages/admin/members-simple.vue create mode 100644 app/pages/admin/members-working.vue create mode 100644 app/pages/admin/test.vue create mode 100644 app/pages/contact.vue create mode 100644 app/pages/events/[id].vue create mode 100644 app/pages/events/index.vue create mode 100644 app/pages/login.vue create mode 100644 plugins/cloudinary.client.js create mode 100644 scripts/migrate-event-slugs.js create mode 100644 scripts/seed-all.js create mode 100644 scripts/seed-events.js create mode 100644 scripts/seed-members.js create mode 100644 server/api/admin/dashboard.get.js create mode 100644 server/api/admin/events.get.js create mode 100644 server/api/admin/events.post.js create mode 100644 server/api/admin/events/[id].delete.js create mode 100644 server/api/admin/events/[id].get.js create mode 100644 server/api/admin/events/[id].put.js create mode 100644 server/api/admin/members.get.js create mode 100644 server/api/admin/members.post.js create mode 100644 server/api/auth/logout.post.js create mode 100644 server/api/auth/verify.get.js create mode 100644 server/api/events/[id].get.js create mode 100644 server/api/events/[id]/register.post.js create mode 100644 server/api/events/index.get.js create mode 100644 server/api/upload/image.post.js create mode 100644 server/models/event.js create mode 100644 server/utils/helcim.js create mode 100644 server/utils/mongoose.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5a02892 --- /dev/null +++ b/.env.example @@ -0,0 +1,23 @@ +# Database Configuration +MONGODB_URI=mongodb://localhost:27017/ghostguild + +# Helcim Payment Configuration (Public - used in frontend) +NUXT_PUBLIC_HELCIM_TOKEN=your-helcim-js-token +NUXT_PUBLIC_HELCIM_ACCOUNT_ID=your-helcim-account-id + +# Helcim API Configuration (Private - server-side only) +HELCIM_API_TOKEN=your-helcim-api-token + +# Email Configuration (Resend) +RESEND_API_KEY=your-resend-api-key +RESEND_FROM_EMAIL=noreply@ghostguild.org + +# Slack Integration +SLACK_WEBHOOK_URL=your-slack-webhook-url +SLACK_OAUTH_TOKEN=your-slack-oauth-token + +# JWT Secret for authentication +JWT_SECRET=your-jwt-secret-key-change-this-in-production + +# Application URLs +APP_URL=http://localhost:3000 \ No newline at end of file diff --git a/README.md b/README.md index 25b5821..f16d76d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Nuxt Minimal Starter +# Ghost Guild is a Nuxt 4 Site Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. diff --git a/app/app.config.ts b/app/app.config.ts new file mode 100644 index 0000000..51f28f0 --- /dev/null +++ b/app/app.config.ts @@ -0,0 +1,8 @@ +export default defineAppConfig({ + ui: { + colors: { + primary: "pink", + neutral: "zinc", + }, + }, +}); diff --git a/app/app.vue b/app/app.vue index 8dd09d3..5acf3c1 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,5 +1,7 @@ diff --git a/app/assets/css/fonts.css b/app/assets/css/fonts.css new file mode 100644 index 0000000..fbcd1c2 --- /dev/null +++ b/app/assets/css/fonts.css @@ -0,0 +1,2 @@ +/* Font declarations are now handled by @nuxt/fonts module */ +/* See nuxt.config.ts for font configuration */ \ No newline at end of file diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 1e3ccb1..a5e24e1 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -1,810 +1,14 @@ - +@import "./fonts.css"; @import "tailwindcss"; @import "@nuxt/ui"; @theme { /* Font families */ - --font-sans: "Inter", "Neue Montreal", sans-serif; + --font-sans: "Inter", sans-serif; --font-body: "Inter", sans-serif; --font-mono: "Ubuntu Mono", monospace; --font-display: "NB Television Pro", monospace; - /* Custom colors */ - --color-lavender-50: #fcf6fd; - --color-lavender-100: #f7ebfc; - --color-lavender-200: #f0d7f7; - --color-lavender-300: #e5b7f0; - --color-lavender-400: #d689e5; - --color-lavender-500: #c25fd6; - --color-lavender-600: #a840b9; - --color-lavender-700: #8d3299; - --color-lavender-800: #752b7d; - --color-lavender-900: #622867; - --color-lavender-950: #3e0f43; - - /* Named colors for glow effects */ - --color-peachFuzz: #ffcc99; - --color-lemonChiffon: #ffff99; - --color-mintSpray: #ccff99; - --color-jadeMist: #99ff99; - --color-aquaFresh: #99ffcc; - --color-skyKiss: #99ccff; - --color-lavenderHush: #9999ff; - --color-purpleHaze: #cc99ff; - --color-pinkBliss: #ff99cc; - --color-body: #cccccc; - /* Animations */ - --animate-float-1: ghostie1 3s cubic-bezier(0.68, -0.55, 0.27, 1.55) infinite alternate; - --animate-float-2: ghostie2 2.5s cubic-bezier(0.445, 0.05, 0.55, 0.95) infinite alternate; - --animate-float-3: ghostie3 2s cubic-bezier(0.445, 0.05, 0.55, 0.95) infinite alternate; } - -/* Keyframes for animations */ -@keyframes ghostie1 { - 0% { transform: translateY(0); } - 100% { transform: translateY(-20px); } -} - -@keyframes ghostie2 { - 0% { transform: translateY(0); } - 100% { transform: translateY(-15px); } -} - -@keyframes ghostie3 { - 0% { transform: translateY(0); } - 100% { transform: translateY(-10px); } -} - -.what-we-do ul { - @apply list-disc list-inside space-y-2; -} - -.what-we-do a { - @apply text-lavender-400 hover:text-lavender-300 transition-colors; -} - -.gradient-text { - @apply text-zinc-50 text-2xl md:text-4xl; - - a& { - text-decoration: underline; - } -} - - -.glow-bg { - position: relative; -} -/* Force dark mode by default - ensures no flash of light content */ -html { - color-scheme: dark; -} - -body { - @apply font-sans antialiased text-zinc-300 bg-zinc-800; - /* background-image: url(/img/background_top_edge.png); */ - background-repeat: repeat-x; - background-size: 20%; - - a:not(.glow-link), a:not(.cta-button) { - @apply hover:brightness-125; - } -} -main { - /* @apply pb-0; */ -} -body, -.rainbow-border { - /* border-top: 1px solid; - border-image: linear-gradient( - to right, - #ff9999, - #ffcc99, - #ffff99, - #ccff99, - #99ff99, - #99ffcc, - #99ccff, - #9999ff, - #cc99ff, - #ff99cc - ); - border-image-slice: 1; */ -} - - -.gradient-header::after { - content: ""; - position: absolute; - left: 0; - bottom: 0; - height: 1px; - width: 100%; - background: linear-gradient( - to right, - #ff9999, - #ffcc99, - #ffff99, - #ccff99, - #99ff99, - #99ffcc, - #99ccff, - #9999ff, - #cc99ff, - #ff99cc - ); -} -.gradient-border { - position: relative; -} - -.gradient-border::before { - content: ""; - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; - z-index: -1; - margin: -2px; /* Adjust to your desired border width */ - border-radius: inherit; - background: linear-gradient( - to right, - #ff9999, - #ffcc99, - #ffff99, - #ccff99, - #99ff99, - #99ffcc, - #99ccff, - #9999ff, - #cc99ff, - #ff99cc - ); -} -@define-mixin text-crt $size { - /* background-image: url("~/assets/img/CRT_screen_pattern_11x16.png"); - background-repeat: repeat; - background-size: 50%; - background-clip: text; - -webkit-background-clip: text; */ - /* mix-blend-mode: exclusion; */ - /* color: transparent; color: rgba(245, 235, 237, 0.7); */ - /* letter-spacing: -1px; */ - /* Adjusting the opacity and spread of the white shadow based on size */ - text-shadow: 2px 0 4px rgba(220, 148, 232, 1), - -2px 0 4px rgba(92, 201, 245, 1), - 0 0 10px rgba(255, 255, 255, 0.22); -} -h1 { - .text-bg { - @apply text-purple-400; - } - font-family: "NB Television Pro", monospace; - letter-spacing: -1px; - font-smooth: auto; - -webkit-font-smoothing: auto; -} - -h2 { - font-family: "NB Television Pro", monospace; - @apply text-3xl; - font-smooth: auto; - -webkit-font-smoothing: auto; - /* @mixin text-crt 0.5; */ -} - -h3 { - @apply uppercase text-2xl font-bold text-lavender-300 mb-0 tracking-wide; - font-family: "NB Television Pro", monospace; - @apply text-3xl mt-8; - font-smooth: auto; - -webkit-font-smoothing: auto; - @mixin text-crt 0.3; - /* letter-spacing: -1px; */ -} - - -.font-display { - font-family: "NB Television Pro", monospace; - font-smooth: auto; - -webkit-font-smoothing: auto; -} -nav { - ul { - li { - a { - font-family: "NB Television Pro", monospace; - @apply text-2xl; - } - } - } -} -p, -li { - @apply font-normal; - strong, - b { - @apply font-bold brightness-125; - } -} - -.text-prose { - @screen md { - @apply mt-5 text-xl; - } - - p { - @apply mt-3 mx-auto max-w-4xl; - - @screen sm { - @apply text-lg; - } - } -} -.bg-zinc-300 { - @apply text-zinc-900; -} - -section { - @apply mb-16 px-6 max-w-7xl; - p a, - li a { - @apply hover:brightness-125 underline; - } - - &:last-child { - /* @apply mb-0; */ - } - section { - @apply px-0 mb-0; - } - - &.wide { - @apply mb-0; - } - @screen md { - /* @apply mb-24 px-12; */ - } - /* todo fix this situation */ - - @screen lg { - &:last-child { - /* @apply mb-0; */ - } - } - p { - @apply mt-3; - } - p, - li { - @apply text-base; - - @screen sm { - @apply text-lg; - } - } - - h2 { - @apply flex-1 mb-2; - } -} -.text-arrow { - @mixin text-crt 0.3; -} -.page--news { - .article-title--excerpt { - @apply text-xl md:text-2xl font-bold text-zinc-200; - } -} -.page--about { - #staff-cards { - /* these are here because we need access to the mixin */ - h2 { - @mixin text-crt 0.4; - @apply text-3xl font-bold text-zinc-300 my-0; - } - h3 { - @apply uppercase font-body text-xs text-zinc-300 mt-0 mb-2 tracking-normal; - } - /* @screen md { - h2 { - @apply text-xl my-0; - } - } */ - } -} - -.page--baby-ghosts { - main { - ul { - @apply list-none m-0 mt-4 p-0; - - li { - @apply relative pl-6 mb-3; - - &:before { - content: "–"; - @apply absolute left-0 top-0; - } - } - } - } - li > ul { - @apply mt-0; - li { - @apply mb-0; - } - } - li, - p { - a { - @apply text-zinc-200 hover:text-zinc-50; - } - } - div + div h3:first-child { - @apply mt-0; - } - h3 { - @apply mt-8 leading-10 mb-0; - - &#coordinators { - @apply lg:mt-0 mt-12; - } - } - section { - @apply px-6; - - &:not(.header):not(.wide) { - @apply text-zinc-300; - } - } - - .timeline { - h4 { - @apply text-2xl leading-10 mt-12 font-bold text-zinc-100 uppercase text-primary; - } - h5 { - @apply text-base mb-6; - } - - strong, - b { - @apply font-bold text-zinc-200; - } - } - - .right-aligner { - h3 { - @apply mb-4 lg:mb-0 lg:text-right text-4xl; - } - ul { - @apply mt-0; - } - } - .cta { - @apply mx-auto w-full text-center pb-12; - @screen md { - @apply pb-0; - } - } - .action-buttons { - @apply flex flex-wrap justify-around my-12; - @screen lg { - @apply my-0; - } - .cta { - @apply inline w-full md:w-1/2 mb-6 md:mb-0; - &:last-child { - @apply mb-0; - } - } - } - h3:not(:first-child), - h4:not(:first-child) { - @apply mt-10; - } -} -.bg-ghostie { - - &.ghostie-double-take { - background-image: url(/img/ghosts/Ghost-Double-Take.svg); - } - &.ghostie-sweet { - background-image: url(/img/ghosts/Ghost-Sweet.svg); - } - &.ghostie-happy { - background-image: url(/img/ghosts/Ghost-Happy.svg); - } - background-repeat: no-repeat; - background-position: right -10px bottom -50px; - background-size: 200px; - background-image: none; - overflow: hidden; - &.flip { - transform: scaleX(-1); - } - /* p, - li { - @apply bg-zinc-300; - } */ -} - -@keyframes wiggle { - 0% { - transform: rotate(0deg); - } - 25% { - transform: rotate(-5deg); - } - 50% { - transform: rotate(0deg); - } - 75% { - transform: rotate(5deg); - } - 100% { - transform: rotate(0deg); - } -} - -@define-mixin glow $color { - text-shadow: 0 0 10px $color, 0 0 20px $color, 0 0 30px $color, - 0 0 40px $color; -} -.router-link-active { - @apply text-white; -} -.glow-link { - @apply no-underline inline-block transition-all duration-300 ease-in-out mr-4; - &.router-link-exact-active { - @apply text-zinc-50; - } - &.peachFuzz { - &:hover, - &.router-link-exact-active { - @mixin glow #ffcc99; - } - } - - &.lemonChiffon { - &:hover, - &.router-link-exact-active { - @mixin glow #ffff99; - } - } - - &.mintSpray { - &:hover, - &.router-link-exact-active { - @mixin glow #ccff99; - } - } - - &.jadeMist { - &:hover, - &.router-link-exact-active { - @mixin glow #99ff99; - } - } - - &.aquaFresh { - &:hover, - &.router-link-exact-active { - @mixin glow #99ffcc; - } - } - - &.skyKiss { - &:hover, - &.router-link-exact-active { - @mixin glow #99ccff; - } - } - - &.lavenderHush { - &:hover, - &.router-link-exact-active { - @mixin glow #9999ff; - } - } - - &.purpleHaze { - &:hover, - &.router-link-exact-active { - @mixin glow #cc99ff; - } - } - - &.pinkBliss { - &:hover, - &.router-link-exact-active { - @mixin glow #ff99cc; - } - } -} -.article-title--excerpt { - @apply text-xl font-bold font-body; -} -.article-title { - @mixin text-crt 0.3; -} -article { - h2 { - @apply text-3xl font-bold mt-8; - } - h3 { - @apply text-2xl font-bold mt-8 uppercase; - } - ul, - ol { - @apply list-none m-0 mt-4 p-0; - - li { - @apply relative pl-6; - - &:before { - content: "–"; - @apply absolute left-0 top-0; - } - } - } -} -ul.formkit-messages { - @apply list-none mt-2 ml-0 p-0 m-0; - li { - @apply relative p-0 m-0; - - &:before { - content: "" !important; - position: absolute; - left: 0; - top: 0; - } - } - .formkit-message { - @apply text-sm inline-block bg-red-300 text-zinc-800 p-2 leading-tight font-bold; - } -} -.glow-bg { - @apply bg-zinc-800; - box-shadow: calc(4px) 0 calc(4px) rgba(220, 148, 232, 1), - calc(-2px) 0 calc(4px) rgba(92, 201, 245, 1), - 0 0 calc(10px) rgba(255, 255, 255, calc(0.1)); -} -.float-section { - h2 { - @apply text-2xl md:text-6xl font-bold text-zinc-300; - } - p { - @apply text-lg md:text-2xl lg:text-3xl text-zinc-300; - } -} -.page--home { - .what-we-do { - ul { - @apply list-none m-0 mt-4 p-0; - - li { - @apply relative pl-6; - - &:before { - content: "–"; - @apply absolute left-0 top-0; - } - } - } - li, - p { - @apply md:text-2xl; - a { - @apply text-zinc-200 hover:text-zinc-50; - } - } - } -} - -.grain-wrapper { - position: fixed; - z-index: 100; - pointer-events: none; -} - -.grain { - mix-blend-mode: hard-light; - height: 100vh; - width: 100vw; - background-image: url(~/assets/img/noise-256w.png); - opacity: 0.1; - - animation: grain 0.4s steps(1) infinite; -} -@media (prefers-reduced-motion) { - .grain { - animation: none; - } -} -@keyframes grain { - 0%, - 100% { - background-position: 0% 0%; - } - 20% { - background-position: 50% 50%; - } - 40% { - background-position: 25% 25%; - } - 60% { - background-position: 75% 75%; - } - 80% { - background-position: 0% 100%; - } -} -.activities-table { - table { - @apply w-full; - } - tbody { - @apply space-y-4; - } - thead { - @apply text-left; - } - th { - @apply text-xl font-bold; - } - td { - @apply text-lg; - } - .activity { - @apply text-lg; - } -} - -/* Time Commitment List Styles */ -.page--peer-support .time-commitment ul, -.page--application ul.eligibility { - @apply space-y-1 text-lg text-zinc-300 mt-2; - li { - @apply pl-6 relative; - &:before { - content: "•"; - @apply absolute left-0 text-lavender-500; - } - } -} - -/* CTA Button Dithered Shadow Effect */ -.cta-button { - position: relative; - isolation: isolate; -} - -.cta-button::before { - content: ""; - position: absolute; - inset: 0; - border-radius: 9999px; - z-index: -2; - background-image: - repeating-linear-gradient( - 45deg, - transparent, - transparent 1px, - rgba(0, 0, 0, 0.3) 1px, - rgba(0, 0, 0, 0.3) 2px - ), - repeating-linear-gradient( - -45deg, - transparent, - transparent 1px, - rgba(0, 0, 0, 0.2) 1px, - rgba(0, 0, 0, 0.2) 2px - ); - transform: translate(4px, 4px); - transition: transform 0.3s ease; -} - -.cta-button::after { - content: ""; - position: absolute; - inset: 0; - border-radius: 9999px; - z-index: -1; - background-image: url('~/assets/img/noise-256w.png'); - background-size: 100px 100px; - opacity: 0.15; - mix-blend-mode: multiply; - transform: translate(6px, 6px); - transition: transform 0.3s ease; -} - -.cta-button:hover::before { - transform: translate(6px, 6px); -} - -.cta-button:hover::after { - transform: translate(8px, 8px); -} - -/* Color variations for dithered shadows */ -.cta-peach::before { - background-image: - repeating-linear-gradient( - 45deg, - transparent, - transparent 1px, - rgba(255, 153, 102, 0.3) 1px, - rgba(255, 153, 102, 0.3) 2px - ), - repeating-linear-gradient( - -45deg, - transparent, - transparent 1px, - rgba(255, 204, 153, 0.2) 1px, - rgba(255, 204, 153, 0.2) 2px - ); -} - -.cta-blue::before { - background-image: - repeating-linear-gradient( - 45deg, - transparent, - transparent 1px, - rgba(37, 99, 235, 0.3) 1px, - rgba(37, 99, 235, 0.3) 2px - ), - repeating-linear-gradient( - -45deg, - transparent, - transparent 1px, - rgba(6, 182, 212, 0.2) 1px, - rgba(6, 182, 212, 0.2) 2px - ); -} - -.cta-green::before { - background-image: - repeating-linear-gradient( - 45deg, - transparent, - transparent 1px, - rgba(34, 197, 94, 0.3) 1px, - rgba(34, 197, 94, 0.3) 2px - ), - repeating-linear-gradient( - -45deg, - transparent, - transparent 1px, - rgba(52, 211, 153, 0.2) 1px, - rgba(52, 211, 153, 0.2) 2px - ); -} - - -.quotes { - @apply p-6 xl:p-0 justify-around text-zinc-300; - p { - @apply block w-full text-xl; - @screen lg { - @apply w-1/3; - } - &:first-child { - @apply block text-2xl w-full; - @screen lg { - @apply w-3/5; - } - } - .attribution { - @apply text-base; - } - } - ul { - @apply mt-0; - } - } \ No newline at end of file diff --git a/app/components/AppFooter.vue b/app/components/AppFooter.vue new file mode 100644 index 0000000..74928a8 --- /dev/null +++ b/app/components/AppFooter.vue @@ -0,0 +1,288 @@ + + + \ No newline at end of file diff --git a/app/components/AppNavigation.vue b/app/components/AppNavigation.vue new file mode 100644 index 0000000..803f8bd --- /dev/null +++ b/app/components/AppNavigation.vue @@ -0,0 +1,102 @@ + + + \ No newline at end of file diff --git a/app/components/ImageUpload.vue b/app/components/ImageUpload.vue new file mode 100644 index 0000000..d4a494a --- /dev/null +++ b/app/components/ImageUpload.vue @@ -0,0 +1,201 @@ + + + \ No newline at end of file diff --git a/app/components/PageHeader.vue b/app/components/PageHeader.vue new file mode 100644 index 0000000..b756f47 --- /dev/null +++ b/app/components/PageHeader.vue @@ -0,0 +1,172 @@ + + + \ No newline at end of file diff --git a/app/config/circles.js b/app/config/circles.js new file mode 100644 index 0000000..e77498e --- /dev/null +++ b/app/config/circles.js @@ -0,0 +1,54 @@ +// Central configuration for Ghost Guild Circles +export const CIRCLES = { + COMMUNITY: { + value: 'community', + label: 'Community Circle', + description: 'For individuals interested in learning about cooperative principles', + features: [ + 'Game workers curious about co-ops, researchers, industry allies', + 'People exploring alternative work models', + 'Access to community forums and resources' + ] + }, + FOUNDER: { + value: 'founder', + label: 'Founder Circle', + description: 'For those actively establishing or growing their cooperative studio', + features: [ + 'Has two tracks: Peer Accelerator Prep Track and Indie Track', + 'Teams working toward PA application', + 'Early-stage co-op studios', + 'Studios transitioning to co-op model' + ] + }, + PRACTITIONER: { + value: 'practitioner', + label: 'Practitioner Circle', + description: 'For Peer Accelerator alumni and experienced studio leaders', + features: [ + 'Those implementing cooperative models', + 'Industry mentors and advisors', + 'Expert-level workshops and mentorship opportunities' + ] + } +}; + +// Get all circle options as an array (useful for forms) +export const getCircleOptions = () => { + return Object.values(CIRCLES); +}; + +// Get valid circle values for validation +export const getValidCircleValues = () => { + return Object.values(CIRCLES).map(circle => circle.value); +}; + +// Get circle by value +export const getCircleByValue = (value) => { + return Object.values(CIRCLES).find(circle => circle.value === value); +}; + +// Check if a circle value is valid +export const isValidCircleValue = (value) => { + return getValidCircleValues().includes(value); +}; \ No newline at end of file diff --git a/app/config/contributions.js b/app/config/contributions.js new file mode 100644 index 0000000..7ce6a38 --- /dev/null +++ b/app/config/contributions.js @@ -0,0 +1,108 @@ +// 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: 'supporter-monthly-5', + 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: 'member-monthly-15', + 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: 'advocate-monthly-30', + 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: 'champion-monthly-50', + 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); +}; \ No newline at end of file diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue new file mode 100644 index 0000000..a9194f7 --- /dev/null +++ b/app/layouts/admin.vue @@ -0,0 +1,220 @@ + + + \ No newline at end of file diff --git a/app/layouts/default.vue b/app/layouts/default.vue new file mode 100644 index 0000000..befc6f9 --- /dev/null +++ b/app/layouts/default.vue @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/app/middleware/admin.js b/app/middleware/admin.js new file mode 100644 index 0000000..2c643a7 --- /dev/null +++ b/app/middleware/admin.js @@ -0,0 +1,18 @@ +export default defineNuxtRouteMiddleware((to) => { + // Skip middleware in server-side rendering to avoid errors + if (process.server) return + + // TODO: Temporarily disabled for testing - enable when authentication is set up + // Check if user is authenticated (you'll need to implement proper auth state) + // const isAuthenticated = useCookie('auth-token').value + + // if (!isAuthenticated) { + // throw createError({ + // statusCode: 401, + // statusMessage: 'Authentication required' + // }) + // } + + // TODO: Add proper role-based authorization + // For now, we assume anyone with a valid token is an admin +}) \ No newline at end of file diff --git a/app/pages/about.vue b/app/pages/about.vue new file mode 100644 index 0000000..44ddd0e --- /dev/null +++ b/app/pages/about.vue @@ -0,0 +1,312 @@ + + + \ No newline at end of file diff --git a/app/pages/admin/dashboard.vue b/app/pages/admin/dashboard.vue new file mode 100644 index 0000000..791261d --- /dev/null +++ b/app/pages/admin/dashboard.vue @@ -0,0 +1,250 @@ + + + \ No newline at end of file diff --git a/app/pages/admin/events-working.vue b/app/pages/admin/events-working.vue new file mode 100644 index 0000000..df6f1ff --- /dev/null +++ b/app/pages/admin/events-working.vue @@ -0,0 +1,361 @@ + + + \ No newline at end of file diff --git a/app/pages/admin/events/create.vue b/app/pages/admin/events/create.vue new file mode 100644 index 0000000..4c4faf7 --- /dev/null +++ b/app/pages/admin/events/create.vue @@ -0,0 +1,594 @@ + + + \ No newline at end of file diff --git a/app/pages/admin/events/index.vue b/app/pages/admin/events/index.vue new file mode 100644 index 0000000..fd13818 --- /dev/null +++ b/app/pages/admin/events/index.vue @@ -0,0 +1,336 @@ + + + \ No newline at end of file diff --git a/app/pages/admin/index-working.vue b/app/pages/admin/index-working.vue new file mode 100644 index 0000000..2115db5 --- /dev/null +++ b/app/pages/admin/index-working.vue @@ -0,0 +1,112 @@ + + + \ No newline at end of file diff --git a/app/pages/admin/index.vue b/app/pages/admin/index.vue new file mode 100644 index 0000000..06cd609 --- /dev/null +++ b/app/pages/admin/index.vue @@ -0,0 +1,250 @@ + + + \ No newline at end of file diff --git a/app/pages/admin/members-simple.vue b/app/pages/admin/members-simple.vue new file mode 100644 index 0000000..9309844 --- /dev/null +++ b/app/pages/admin/members-simple.vue @@ -0,0 +1,101 @@ + + + \ No newline at end of file diff --git a/app/pages/admin/members-working.vue b/app/pages/admin/members-working.vue new file mode 100644 index 0000000..685cfb4 --- /dev/null +++ b/app/pages/admin/members-working.vue @@ -0,0 +1,229 @@ + + + \ No newline at end of file diff --git a/app/pages/admin/members.vue b/app/pages/admin/members.vue index 1e43ecb..685cfb4 100644 --- a/app/pages/admin/members.vue +++ b/app/pages/admin/members.vue @@ -1,38 +1,229 @@ - +const searchQuery = ref('') +const circleFilter = ref('') +const showCreateModal = ref(false) +const creating = ref(false) + +const newMember = reactive({ + name: '', + email: '', + circle: 'community', + contributionTier: '0' +}) + +const filteredMembers = computed(() => { + if (!members.value) return [] + + return members.value.filter(member => { + const matchesSearch = !searchQuery.value || + member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) || + member.email.toLowerCase().includes(searchQuery.value.toLowerCase()) + + const matchesCircle = !circleFilter.value || member.circle === circleFilter.value + + return matchesSearch && matchesCircle + }) +}) + +const getCircleClasses = (circle) => { + const classes = { + community: 'bg-blue-100 text-blue-800', + founder: 'bg-purple-100 text-purple-800', + practitioner: 'bg-green-100 text-green-800' + } + return classes[circle] || 'bg-gray-100 text-gray-800' +} + +const formatDate = (dateString) => { + return new Date(dateString).toLocaleDateString() +} + +const createMember = async () => { + creating.value = true + try { + await $fetch('/api/admin/members', { + method: 'POST', + body: newMember + }) + + showCreateModal.value = false + Object.assign(newMember, { + name: '', + email: '', + circle: 'community', + contributionTier: '0' + }) + + await refresh() + alert('Member created successfully!') + } catch (error) { + console.error('Failed to create member:', error) + alert('Failed to create member') + } finally { + creating.value = false + } +} + +const sendSlackInvite = (member) => { + alert(`Slack invite functionality would send invite to ${member.email}`) + console.log('Send Slack invite to:', member.email) +} + +const editMember = (member) => { + alert(`Edit functionality would open editor for ${member.name}`) + console.log('Edit member:', member._id) +} + \ No newline at end of file diff --git a/app/pages/admin/test.vue b/app/pages/admin/test.vue new file mode 100644 index 0000000..8bf3b1b --- /dev/null +++ b/app/pages/admin/test.vue @@ -0,0 +1,30 @@ + + + \ No newline at end of file diff --git a/app/pages/contact.vue b/app/pages/contact.vue new file mode 100644 index 0000000..6911855 --- /dev/null +++ b/app/pages/contact.vue @@ -0,0 +1,322 @@ + + + \ No newline at end of file diff --git a/app/pages/events/[id].vue b/app/pages/events/[id].vue new file mode 100644 index 0000000..6b90e6a --- /dev/null +++ b/app/pages/events/[id].vue @@ -0,0 +1,417 @@ + + + \ No newline at end of file diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue new file mode 100644 index 0000000..dcbd7b1 --- /dev/null +++ b/app/pages/events/index.vue @@ -0,0 +1,390 @@ + + + + + \ No newline at end of file diff --git a/app/pages/index.vue b/app/pages/index.vue index 5855392..4eedc1f 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -1,48 +1,104 @@ - +import { getCircleOptions } from '~/config/circles' + +const circles = getCircleOptions() + \ No newline at end of file diff --git a/app/pages/join.vue b/app/pages/join.vue index 73c7249..8413dd1 100644 --- a/app/pages/join.vue +++ b/app/pages/join.vue @@ -1,63 +1,357 @@ - +// Form validation +const isFormValid = computed(() => { + return form.name && form.email && form.circle && form.contributionTier +}) + +// Form submission - redirect to detailed form +const handleSubmit = async () => { + if (isSubmitting.value) return + + // For now, just scroll to the form or redirect to detailed signup + const formElement = document.getElementById('membership-form') + if (formElement) { + formElement.scrollIntoView({ behavior: 'smooth' }) + } else { + // Could redirect to a detailed form page + await navigateTo('/join/details') + } +} + \ No newline at end of file diff --git a/app/pages/login.vue b/app/pages/login.vue new file mode 100644 index 0000000..5a8cb6d --- /dev/null +++ b/app/pages/login.vue @@ -0,0 +1,377 @@ + + + \ No newline at end of file diff --git a/app/pages/members/index.vue b/app/pages/members/index.vue index 61b650a..831e755 100644 --- a/app/pages/members/index.vue +++ b/app/pages/members/index.vue @@ -9,7 +9,8 @@
-

{{ member?.circle }}

+

{{ circleLabel }}

+

{{ circleDescription }}

Request Circle Change @@ -17,10 +18,8 @@ -

- ${{ member?.contributionTier }}/month -

-

Supporting 2 solidarity spots

+

{{ contributionLabel }}

+

Supporting 2 solidarity spots

Adjust Contribution @@ -38,3 +37,32 @@ + + diff --git a/nuxt.config.ts b/nuxt.config.ts index c314696..f96b896 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -3,8 +3,26 @@ export default defineNuxtConfig({ compatibilityDate: "2025-07-15", devtools: { enabled: true }, modules: ["@nuxt/eslint", "@nuxt/ui", "@nuxtjs/plausible"], + build: { + transpile: ['vue-cal'] + }, plausible: { domain: "ghostguild.org", }, css: ["~/assets/css/main.css"], + runtimeConfig: { + // Private keys (server-side only) + mongodbUri: process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild', + jwtSecret: process.env.JWT_SECRET || 'dev-secret-change-in-production', + resendApiKey: process.env.RESEND_API_KEY || '', + helcimApiToken: process.env.HELCIM_API_TOKEN || '', + + // Public keys (available on client-side) + public: { + helcimToken: process.env.NUXT_PUBLIC_HELCIM_TOKEN || '', + helcimAccountId: process.env.NUXT_PUBLIC_HELCIM_ACCOUNT_ID || '', + cloudinaryCloudName: process.env.NUXT_PUBLIC_CLOUDINARY_CLOUD_NAME || 'divzuumlr', + appUrl: process.env.NUXT_PUBLIC_APP_URL || 'http://localhost:3000' + } + } }); diff --git a/package-lock.json b/package-lock.json index 6bc2e7b..c350064 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,14 @@ "name": "nuxt-app", "hasInstallScript": true, "dependencies": { + "@cloudinary/vue": "^1.13.3", + "@headlessui/vue": "^1.7.23", + "@heroicons/vue": "^2.2.0", "@nuxt/eslint": "^1.9.0", "@nuxt/ui": "^3.3.2", "@nuxtjs/plausible": "^1.2.0", "bcryptjs": "^3.0.2", + "cloudinary": "^2.7.0", "eslint": "^9.34.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.18.0", @@ -19,7 +23,9 @@ "resend": "^6.0.1", "typescript": "^5.9.2", "vue": "^3.5.20", - "vue-router": "^4.5.1" + "vue-cal": "^5.0.1-rc.28", + "vue-router": "^4.5.1", + "zod": "^4.1.3" } }, "node_modules/@alloc/quick-lru": { @@ -552,6 +558,47 @@ "node": ">=10.0.0" } }, + "node_modules/@cloudinary/html": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@cloudinary/html/-/html-1.13.4.tgz", + "integrity": "sha512-noBk9D2VZgZkQIs5/y29OsJDmwp0FtgAlKrrp1+0Jp2HMu+68sdDJ3zi4/ZuLCnbdqthGuxP83trowG+Zsa68Q==", + "dependencies": { + "@types/lodash.clonedeep": "^4.5.6", + "@types/lodash.debounce": "^4.0.6", + "@types/node": "^14.14.10", + "lodash.clonedeep": "^4.5.0", + "lodash.debounce": "^4.0.8", + "typescript": "^4.1.2" + } + }, + "node_modules/@cloudinary/html/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@cloudinary/html/node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/@cloudinary/vue": { + "version": "1.13.3", + "resolved": "https://registry.npmjs.org/@cloudinary/vue/-/vue-1.13.3.tgz", + "integrity": "sha512-/KWn9Ohf6peQQ0iZEnpFCCUhr6Opa6X1pKxET4uJlgFRtkThJQrCj+17m3fKcwaw9dfwcZ8pfv/EJ5T+rK/W0A==", + "license": "MIT", + "dependencies": { + "@cloudinary/html": "^1.13.4" + } + }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", @@ -1427,6 +1474,30 @@ } } }, + "node_modules/@headlessui/vue": { + "version": "1.7.23", + "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz", + "integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==", + "license": "MIT", + "dependencies": { + "@tanstack/vue-virtual": "^3.0.0-beta.60" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@heroicons/vue": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@heroicons/vue/-/vue-2.2.0.tgz", + "integrity": "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==", + "license": "MIT", + "peerDependencies": { + "vue": ">= 3" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2332,6 +2403,15 @@ "@esbuild/win32-x64": "0.25.5" } }, + "node_modules/@netlify/zip-it-and-ship-it/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -4992,6 +5072,30 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==", + "license": "MIT" + }, + "node_modules/@types/lodash.clonedeep": { + "version": "4.5.9", + "resolved": "https://registry.npmjs.org/@types/lodash.clonedeep/-/lodash.clonedeep-4.5.9.tgz", + "integrity": "sha512-19429mWC+FyaAhOLzsS8kZUsI+/GmBAQ0HFiCPsKGU+7pBXOQWhyrY6xNNDwUSX8SMZMJvuFVMF9O5dQOlQK9Q==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/lodash.debounce": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/lodash.debounce/-/lodash.debounce-4.0.9.tgz", + "integrity": "sha512-Ma5JcgTREwpLRwMM+XwBR7DaWe96nC38uCBDFKZWbNKD+osjVzdpnUSwBcqCptrp16sSOLBAUb50Car5I0TCsQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/node": { "version": "24.3.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", @@ -7033,6 +7137,19 @@ "node": ">=0.8" } }, + "node_modules/cloudinary": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.7.0.tgz", + "integrity": "sha512-qrqDn31+qkMCzKu1GfRpzPNAO86jchcNwEHCUiqvPHNSFqu7FTNF9FuAkBUyvM1CFFgFPu64NT0DyeREwLwK0w==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/cluster-key-slot": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", @@ -10830,6 +10947,12 @@ "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", "license": "MIT" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -13085,6 +13208,17 @@ "node": ">=6" } }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", @@ -15698,6 +15832,18 @@ "ufo": "^1.6.1" } }, + "node_modules/vue-cal": { + "version": "5.0.1-rc.28", + "resolved": "https://registry.npmjs.org/vue-cal/-/vue-cal-5.0.1-rc.28.tgz", + "integrity": "sha512-rc4nGXdNFX1VbCqsdiVPeLR5oE9XLzJZ+lLBYfLZfnVjibZE2KYTQ7Ro7gx1s4ECqOMwG5U0frSMQpBpQpTFSQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antoniandre" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/vue-component-type-helpers": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-3.0.6.tgz", @@ -16219,9 +16365,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.3.tgz", + "integrity": "sha512-1neef4bMce1hNTrxvHVKxWjKfGDn0oAli3Wy1Uwb7TRO1+wEwoZUZNP1NXIEESybOBiFnBOhI6a4m6tCLE8dog==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index d047f4f..241bacf 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,14 @@ "postinstall": "nuxt prepare" }, "dependencies": { + "@cloudinary/vue": "^1.13.3", + "@headlessui/vue": "^1.7.23", + "@heroicons/vue": "^2.2.0", "@nuxt/eslint": "^1.9.0", "@nuxt/ui": "^3.3.2", "@nuxtjs/plausible": "^1.2.0", "bcryptjs": "^3.0.2", + "cloudinary": "^2.7.0", "eslint": "^9.34.0", "jsonwebtoken": "^9.0.2", "mongoose": "^8.18.0", @@ -22,6 +26,8 @@ "resend": "^6.0.1", "typescript": "^5.9.2", "vue": "^3.5.20", - "vue-router": "^4.5.1" + "vue-cal": "^5.0.1-rc.28", + "vue-router": "^4.5.1", + "zod": "^4.1.3" } } diff --git a/plugins/cloudinary.client.js b/plugins/cloudinary.client.js new file mode 100644 index 0000000..a345a31 --- /dev/null +++ b/plugins/cloudinary.client.js @@ -0,0 +1,17 @@ +import { Cloudinary } from '@cloudinary/url-gen' + +export default defineNuxtPlugin(() => { + const config = useRuntimeConfig() + + const cloudinary = new Cloudinary({ + cloud: { + cloudName: config.public.cloudinaryCloudName + } + }) + + return { + provide: { + cloudinary + } + } +}) \ No newline at end of file diff --git a/scripts/migrate-event-slugs.js b/scripts/migrate-event-slugs.js new file mode 100644 index 0000000..0a5d877 --- /dev/null +++ b/scripts/migrate-event-slugs.js @@ -0,0 +1,66 @@ +import mongoose from 'mongoose' +import Event from '../server/models/event.js' +import { connectDB } from '../server/utils/mongoose.js' + +// Generate slug from title +function generateSlug(title) { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +async function migrateEventSlugs() { + try { + // Connect to database + await connectDB() + console.log('Connected to database') + + // Find all events without slugs + const eventsWithoutSlugs = await Event.find({ + $or: [ + { slug: { $exists: false } }, + { slug: null }, + { slug: '' } + ] + }) + + console.log(`Found ${eventsWithoutSlugs.length} events without slugs`) + + if (eventsWithoutSlugs.length === 0) { + console.log('All events already have slugs!') + return + } + + // Generate and assign unique slugs + for (const event of eventsWithoutSlugs) { + let baseSlug = generateSlug(event.title) + let slug = baseSlug + let counter = 1 + + // Ensure slug is unique + while (await Event.findOne({ slug, _id: { $ne: event._id } })) { + slug = `${baseSlug}-${counter}` + counter++ + } + + event.slug = slug + await event.save({ validateBeforeSave: false }) // Skip validation to avoid pre-save hook + console.log(`✓ Generated slug "${slug}" for event "${event.title}"`) + } + + console.log(`Successfully migrated ${eventsWithoutSlugs.length} events!`) + } catch (error) { + console.error('Error migrating event slugs:', error) + } finally { + await mongoose.connection.close() + console.log('Database connection closed') + } +} + +// Run migration if called directly +if (import.meta.url === `file://${process.argv[1]}`) { + migrateEventSlugs() +} + +export default migrateEventSlugs \ No newline at end of file diff --git a/scripts/seed-all.js b/scripts/seed-all.js new file mode 100644 index 0000000..75d5980 --- /dev/null +++ b/scripts/seed-all.js @@ -0,0 +1,44 @@ +import mongoose from 'mongoose' +import { connectDB } from '../server/utils/mongoose.js' +import dotenv from 'dotenv' + +// Load environment variables +dotenv.config() + +// Import seed functions +import { execSync } from 'child_process' + +async function seedAll() { + try { + console.log('🌱 Starting database seeding...\n') + + // Seed members + console.log('👥 Seeding members...') + execSync('node scripts/seed-members.js', { stdio: 'inherit' }) + + console.log('\n🎉 Seeding events...') + execSync('node scripts/seed-events.js', { stdio: 'inherit' }) + + console.log('\n✅ All data seeded successfully!') + console.log('\n📊 Database Summary:') + + // Connect and show final counts + await connectDB() + + const Member = (await import('../server/models/member.js')).default + const Event = (await import('../server/models/event.js')).default + + const memberCount = await Member.countDocuments() + const eventCount = await Event.countDocuments() + + console.log(` Members: ${memberCount}`) + console.log(` Events: ${eventCount}`) + + process.exit(0) + } catch (error) { + console.error('❌ Error seeding database:', error) + process.exit(1) + } +} + +seedAll() \ No newline at end of file diff --git a/scripts/seed-events.js b/scripts/seed-events.js new file mode 100644 index 0000000..88b6d89 --- /dev/null +++ b/scripts/seed-events.js @@ -0,0 +1,282 @@ +import mongoose from 'mongoose' +import Event from '../server/models/event.js' +import { connectDB } from '../server/utils/mongoose.js' +import dotenv from 'dotenv' + +// Load environment variables +dotenv.config() + +// Generate future dates relative to today +const today = new Date() +const nextMonth = new Date(today) +nextMonth.setMonth(nextMonth.getMonth() + 1) + +// Generate slug from title +function generateSlug(title) { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +const sampleEvents = [ + { + title: 'Monthly Community Meetup', + tagline: 'Connect, share, and learn with fellow cooperative game developers', + description: 'Join us for our monthly community gathering where developers share experiences, discuss cooperative models, and network with fellow members.', + featureImage: { + url: 'https://images.unsplash.com/photo-1556761175-b413da4baf72?w=1200&h=400&fit=crop', + publicId: 'samples/community-meetup', + alt: 'Developers collaborating at a community meetup' + }, + content: 'This informal meetup is perfect for connecting with other developers interested in cooperative business models. We\'ll have brief presentations, open discussions, and time for networking.\n\nAgenda:\n- Welcome & introductions\n- Member spotlight presentations\n- Open discussion on cooperative challenges and successes\n- Networking and social time', + startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7, 19, 0), + endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 7, 21, 0), + eventType: 'community', + location: '#general', + isOnline: true, + membersOnly: false, + isVisible: true, + isCancelled: false, + targetCircles: ['community', 'founder'], + maxAttendees: 50, + registrationRequired: true, + registrationDeadline: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 5, 23, 59), + agenda: [ + 'Welcome & introductions (15 min)', + 'Member spotlight presentations (30 min)', + 'Open discussion on cooperative challenges and successes (45 min)', + 'Networking and social time (30 min)' + ], + createdBy: 'admin@ghostguild.org' + }, + { + title: 'Cooperative Business Structures Workshop', + tagline: 'Learn how to structure and run a successful game development cooperative', + description: 'An in-depth workshop covering the legal and practical aspects of forming and operating a cooperative business.', + featureImage: { + url: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=1200&h=400&fit=crop', + publicId: 'samples/business-workshop', + alt: 'Business planning workshop with charts and collaboration' + }, + content: 'Learn the fundamentals of cooperative business structures, including legal requirements, governance models, and financial considerations.\n\nTopics covered:\n- Types of cooperative structures\n- Legal requirements and incorporation process\n- Democratic governance and decision-making\n- Profit sharing and member equity\n- Case studies from successful game development cooperatives', + startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 14, 14, 0), + endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 14, 17, 0), + eventType: 'workshop', + location: 'https://zoom.us/j/123456789', + isOnline: true, + membersOnly: false, + isVisible: true, + isCancelled: false, + targetCircles: ['founder', 'practitioner'], + maxAttendees: 30, + agenda: [ + 'Introduction to Cooperative Business Models (30 min)', + 'Legal Structures and Formation (45 min)', + 'Democratic Governance in Creative Teams (45 min)', + 'Break (15 min)', + 'Financial Management and Profit Sharing (45 min)', + 'Case Studies: Successful Game Co-ops (30 min)', + 'Q&A and Networking (30 min)' + ], + speakers: [ + { + name: 'Alex Rivera', + role: 'Co-founder, Pixel Collective', + bio: '10+ years running a successful game development co-op' + }, + { + name: 'Sam Chen', + role: 'Legal Advisor', + bio: 'Specializes in cooperative business law and formation' + } + ], + registrationRequired: true, + registrationDeadline: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 12, 23, 59), + createdBy: 'admin@ghostguild.org' + }, + { + title: 'Game Development Co-op Showcase', + tagline: 'See what our member studios are creating', + description: 'Member studios present their latest projects and share insights about developing games in a cooperative environment.', + featureImage: { + url: 'https://images.unsplash.com/photo-1511512578047-dfb367046420?w=1200&h=400&fit=crop', + publicId: 'samples/game-showcase', + alt: 'Game development showcase with screens displaying games' + }, + content: 'Our quarterly showcase featuring presentations from Ghost Guild member studios. Learn about ongoing projects, cooperative development processes, and the unique challenges and benefits of collaborative game creation.\n\nFeatured presentations:\n- "Collaborative Level Design in Practice"\n- "Democratic Decision Making in Creative Projects"\n- "Balancing Individual Creativity with Group Consensus"\n- Q&A with presenting studios', + startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 21, 18, 30), + endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 21, 21, 0), + eventType: 'showcase', + location: '#showcase', + isOnline: true, + membersOnly: true, + isVisible: true, + isCancelled: false, + targetCircles: ['founder', 'practitioner'], + maxAttendees: 75, + agenda: [ + 'Welcome and introductions (15 min)', + 'Studio presentation: Collaborative Level Design in Practice (20 min)', + 'Studio presentation: Democratic Decision Making in Creative Projects (20 min)', + 'Studio presentation: Balancing Individual Creativity with Group Consensus (20 min)', + 'Panel Q&A with presenting studios (30 min)', + 'Networking and demos (45 min)' + ], + registrationRequired: true, + registrationDeadline: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 19, 23, 59), + createdBy: 'admin@ghostguild.org' + }, + { + title: 'New Year Social Mixer', + tagline: 'Celebrate and connect with the Ghost Guild community', + description: 'Celebrate the new year with fellow Ghost Guild members in a relaxed social atmosphere.', + content: 'Join us for a casual evening of celebration, networking, and community building. Perfect for new members to meet the community and for existing members to catch up.\n\nActivities:\n- Welcome reception\n- Casual networking\n- Community achievements celebration\n- Light refreshments provided\n- Optional lightning talks (5 min, informal)', + startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3, 18, 0), + endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 3, 21, 0), + eventType: 'social', + location: '#social', + isOnline: true, + membersOnly: true, + isVisible: true, + isCancelled: false, + targetCircles: ['community', 'founder', 'practitioner'], + registrationRequired: false, + createdBy: 'admin@ghostguild.org' + }, + { + title: 'Funding Your Game Co-op Panel', + tagline: 'Explore funding strategies for cooperative game studios', + description: 'Panel discussion with successful co-op founders and supporting investors about funding strategies for cooperative game studios.', + content: 'Learn about different funding approaches for cooperative game development studios from those who have successfully navigated the process.\n\nPanelists:\n- Founder of successful indie co-op studio\n- Impact investor specializing in cooperative businesses\n- Grant specialist for creative cooperatives\n- Community development financial institution representative\n\nTopics:\n- Traditional vs. alternative funding models\n- Grant opportunities for cooperatives\n- Community-supported development\n- Investor relations in cooperative structures', + startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 28, 19, 0), + endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 28, 21, 0), + eventType: 'workshop', + location: 'https://meet.google.com/abc-defg-hij', + isOnline: true, + membersOnly: false, + isVisible: true, + isCancelled: false, + targetCircles: ['founder', 'practitioner'], + maxAttendees: 100, + speakers: [ + { + name: 'Maria Garcia', + role: 'Founder, Collective Games Studio', + bio: 'Successfully raised $500K for her cooperative game studio' + }, + { + name: 'David Park', + role: 'Impact Investor', + bio: 'Specializes in funding cooperative and social enterprises' + }, + { + name: 'Jennifer Wu', + role: 'Grant Specialist', + bio: 'Helped secure over $2M in grants for creative cooperatives' + } + ], + registrationRequired: true, + registrationDeadline: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 26, 23, 59), + createdBy: 'admin@ghostguild.org' + }, + { + title: 'Intro to Game Cooperatives', + tagline: 'Learn the basics of cooperative game development', + description: 'A beginner-friendly introduction to cooperative business models in game development.', + content: 'Perfect for developers who are curious about cooperatives but don\'t know where to start. We\'ll cover the basics of what makes a cooperative different, the benefits and challenges, and how to get started.\n\nTopics covered:\n- What is a cooperative?\n- Benefits of the cooperative model\n- Common challenges and solutions\n- Resources for learning more\n- Q&A session', + startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 10, 18, 0), + endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 10, 20, 0), + eventType: 'workshop', + location: 'https://teams.microsoft.com/meet/123456', + isOnline: true, + membersOnly: false, + isVisible: true, + isCancelled: false, + targetCircles: ['community'], + maxAttendees: 50, + registrationRequired: true, + registrationDeadline: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 8, 23, 59), + agenda: [ + 'Welcome and introductions (10 min)', + 'What is a cooperative? (20 min)', + 'Benefits of the cooperative model (20 min)', + 'Common challenges and solutions (20 min)', + 'Getting started with your co-op (20 min)', + 'Resources and next steps (15 min)', + 'Q&A session (15 min)' + ], + createdBy: 'admin@ghostguild.org' + }, + { + title: 'Advanced Co-op Leadership Workshop', + tagline: 'Deep dive into cooperative leadership principles', + description: 'An advanced workshop for experienced cooperative practitioners exploring leadership models.', + content: 'This workshop is designed for practitioners who have experience with cooperative models and want to develop their leadership skills.', + startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 35, 14, 0), + endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 35, 17, 0), + eventType: 'workshop', + location: '#private-workshops', + isOnline: true, + membersOnly: true, + isVisible: false, // Hidden from public calendar + isCancelled: false, + targetCircles: ['practitioner'], + maxAttendees: 15, + registrationRequired: true, + registrationDeadline: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 33, 23, 59), + createdBy: 'admin@ghostguild.org' + }, + { + title: 'Game Development Co-op Meetup - February', + tagline: 'CANCELLED - Will be rescheduled', + description: 'Monthly community meetup - this session has been cancelled due to scheduling conflicts.', + content: 'Our February meetup has been cancelled but will be rescheduled soon. Stay tuned for updates!', + startDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 42, 19, 0), + endDate: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 42, 21, 0), + eventType: 'community', + location: '#general', + isOnline: true, + membersOnly: false, + isVisible: true, + isCancelled: true, + cancellationMessage: 'This meetup has been cancelled due to speaker availability conflicts. We are working to reschedule it for early March with our originally planned speakers. All registered participants will be notified as soon as the new date is confirmed.', + targetCircles: ['community', 'founder'], + maxAttendees: 50, + registrationRequired: true, + registrationDeadline: new Date(today.getFullYear(), today.getMonth(), today.getDate() + 40, 23, 59), + createdBy: 'admin@ghostguild.org' + } +] + +async function seedEvents() { + try { + await connectDB() + + // Clear existing events + await Event.deleteMany({}) + console.log('Cleared existing events') + + // Insert sample events one by one with generated slugs + for (let eventData of sampleEvents) { + // Add slug if not present + if (!eventData.slug) { + eventData.slug = generateSlug(eventData.title) + } + const event = new Event(eventData) + await event.save() + } + console.log(`Added ${sampleEvents.length} sample events`) + + // Verify insertion + const count = await Event.countDocuments() + console.log(`Total events in database: ${count}`) + + process.exit(0) + } catch (error) { + console.error('Error seeding events:', error) + process.exit(1) + } +} + +seedEvents() \ No newline at end of file diff --git a/scripts/seed-members.js b/scripts/seed-members.js new file mode 100644 index 0000000..be6bf58 --- /dev/null +++ b/scripts/seed-members.js @@ -0,0 +1,199 @@ +import mongoose from 'mongoose' +import Member from '../server/models/member.js' +import { connectDB } from '../server/utils/mongoose.js' +import dotenv from 'dotenv' + +// Load environment variables +dotenv.config() + +const sampleMembers = [ + { + email: 'alex.rivera@pixelcollective.coop', + name: 'Alex Rivera', + circle: 'founder', + contributionTier: '50', + slackInvited: true, + createdAt: new Date('2024-01-15'), + lastLogin: new Date('2025-08-20') + }, + { + email: 'sam.chen@legalcoop.com', + name: 'Sam Chen', + circle: 'practitioner', + contributionTier: '30', + slackInvited: true, + createdAt: new Date('2024-02-03'), + lastLogin: new Date('2025-08-18') + }, + { + email: 'maria.garcia@collectivegames.coop', + name: 'Maria Garcia', + circle: 'founder', + contributionTier: '50', + helcimCustomerId: 'cust_12345', + helcimSubscriptionId: 'sub_67890', + slackInvited: true, + createdAt: new Date('2024-03-10'), + lastLogin: new Date('2025-08-25') + }, + { + email: 'david.park@impactinvest.org', + name: 'David Park', + circle: 'practitioner', + contributionTier: '30', + slackInvited: true, + createdAt: new Date('2024-04-12'), + lastLogin: new Date('2025-08-22') + }, + { + email: 'jennifer.wu@grantspecialist.org', + name: 'Jennifer Wu', + circle: 'practitioner', + contributionTier: '15', + slackInvited: true, + createdAt: new Date('2024-05-08'), + lastLogin: new Date('2025-08-19') + }, + { + email: 'jordan.lee@indiedev.com', + name: 'Jordan Lee', + circle: 'community', + contributionTier: '15', + slackInvited: false, + createdAt: new Date('2024-06-20'), + lastLogin: new Date('2025-08-15') + }, + { + email: 'taylor.smith@gamemaker.studio', + name: 'Taylor Smith', + circle: 'community', + contributionTier: '5', + slackInvited: true, + createdAt: new Date('2024-07-15'), + lastLogin: new Date('2025-08-10') + }, + { + email: 'casey.wong@studiocoop.dev', + name: 'Casey Wong', + circle: 'founder', + contributionTier: '30', + helcimCustomerId: 'cust_54321', + slackInvited: true, + createdAt: new Date('2024-08-01'), + lastLogin: new Date('2025-08-24') + }, + { + email: 'riley.johnson@cooperativedev.org', + name: 'Riley Johnson', + circle: 'community', + contributionTier: '0', + slackInvited: false, + createdAt: new Date('2024-08-15'), + lastLogin: new Date('2025-08-12') + }, + { + email: 'morgan.davis@gamecollective.coop', + name: 'Morgan Davis', + circle: 'founder', + contributionTier: '50', + helcimCustomerId: 'cust_98765', + helcimSubscriptionId: 'sub_13579', + slackInvited: true, + createdAt: new Date('2024-09-01'), + lastLogin: new Date('2025-08-26') + }, + { + email: 'avery.brown@newdevstudio.com', + name: 'Avery Brown', + circle: 'community', + contributionTier: '5', + slackInvited: false, + createdAt: new Date('2024-10-10'), + lastLogin: new Date('2025-08-14') + }, + { + email: 'phoenix.martinez@coopgames.dev', + name: 'Phoenix Martinez', + circle: 'practitioner', + contributionTier: '15', + slackInvited: true, + createdAt: new Date('2024-11-05'), + lastLogin: new Date('2025-08-21') + }, + { + email: 'sage.anderson@collaborativestudio.org', + name: 'Sage Anderson', + circle: 'community', + contributionTier: '15', + slackInvited: true, + createdAt: new Date('2024-12-01'), + lastLogin: new Date('2025-08-16') + }, + { + email: 'dakota.wilson@indieguildstudio.com', + name: 'Dakota Wilson', + circle: 'founder', + contributionTier: '30', + slackInvited: true, + createdAt: new Date('2025-01-10'), + lastLogin: new Date('2025-08-23') + }, + { + email: 'charlie.thompson@gamecooperative.net', + name: 'Charlie Thompson', + circle: 'practitioner', + contributionTier: '50', + helcimCustomerId: 'cust_11111', + helcimSubscriptionId: 'sub_22222', + slackInvited: true, + createdAt: new Date('2025-02-14'), + lastLogin: new Date('2025-08-25') + } +] + +async function seedMembers() { + try { + await connectDB() + + // Clear existing members + await Member.deleteMany({}) + console.log('Cleared existing members') + + // Insert sample members + await Member.insertMany(sampleMembers) + console.log(`Added ${sampleMembers.length} sample members`) + + // Verify insertion and show summary + const count = await Member.countDocuments() + console.log(`Total members in database: ${count}`) + + // Show breakdown by circle + const circleBreakdown = await Member.aggregate([ + { $group: { _id: '$circle', count: { $sum: 1 } } }, + { $sort: { _id: 1 } } + ]) + + console.log('\nBreakdown by circle:') + circleBreakdown.forEach(circle => { + console.log(` ${circle._id}: ${circle.count} members`) + }) + + // Show breakdown by contribution tier + const tierBreakdown = await Member.aggregate([ + { $group: { _id: '$contributionTier', count: { $sum: 1 } } }, + { $sort: { _id: 1 } } + ]) + + console.log('\nBreakdown by contribution tier:') + tierBreakdown.forEach(tier => { + console.log(` $${tier._id}: ${tier.count} members`) + }) + + process.exit(0) + } catch (error) { + console.error('Error seeding members:', error) + process.exit(1) + } +} + +seedMembers() \ No newline at end of file diff --git a/server/api/admin/dashboard.get.js b/server/api/admin/dashboard.get.js new file mode 100644 index 0000000..5af6cec --- /dev/null +++ b/server/api/admin/dashboard.get.js @@ -0,0 +1,70 @@ +import Member from '../../models/member.js' +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' +import jwt from 'jsonwebtoken' + +export default defineEventHandler(async (event) => { + try { + // TODO: Temporarily disabled auth for testing - enable when authentication is set up + // Basic auth check + // const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '') + + // if (!token) { + // throw createError({ + // statusCode: 401, + // statusMessage: 'Authentication required' + // }) + // } + + // const config = useRuntimeConfig() + // jwt.verify(token, config.jwtSecret) + + await connectDB() + + // Get stats + const totalMembers = await Member.countDocuments() + const now = new Date() + const activeEvents = await Event.countDocuments({ + startDate: { $lte: now }, + endDate: { $gte: now } + }) + + // Calculate monthly revenue from member contributions + const members = await Member.find({}, 'contributionTier').lean() + const monthlyRevenue = members.reduce((total, member) => { + return total + parseInt(member.contributionTier || '0') + }, 0) + + const pendingSlackInvites = await Member.countDocuments({ slackInvited: false }) + + // Get recent members (last 5) + const recentMembers = await Member.find() + .sort({ createdAt: -1 }) + .limit(5) + .lean() + + // Get upcoming events (next 5) + const upcomingEvents = await Event.find({ + startDate: { $gte: now } + }) + .sort({ startDate: 1 }) + .limit(5) + .lean() + + return { + stats: { + totalMembers, + activeEvents, + monthlyRevenue, + pendingSlackInvites + }, + recentMembers, + upcomingEvents + } + } catch (error) { + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch dashboard data' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/events.get.js b/server/api/admin/events.get.js new file mode 100644 index 0000000..832b642 --- /dev/null +++ b/server/api/admin/events.get.js @@ -0,0 +1,34 @@ +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' +import jwt from 'jsonwebtoken' + +export default defineEventHandler(async (event) => { + try { + // TODO: Temporarily disabled auth for testing - enable when authentication is set up + // Basic auth check - you may want to implement proper admin role checking + // const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '') + + // if (!token) { + // throw createError({ + // statusCode: 401, + // statusMessage: 'Authentication required' + // }) + // } + + // const config = useRuntimeConfig() + // jwt.verify(token, config.jwtSecret) + + await connectDB() + + const events = await Event.find() + .sort({ startDate: 1 }) + .lean() + + return events + } catch (error) { + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch events' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/events.post.js b/server/api/admin/events.post.js new file mode 100644 index 0000000..5e34869 --- /dev/null +++ b/server/api/admin/events.post.js @@ -0,0 +1,50 @@ +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' +import jwt from 'jsonwebtoken' + +export default defineEventHandler(async (event) => { + try { + // TODO: Temporarily disabled auth for testing - enable when authentication is set up + // const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '') + + // if (!token) { + // throw createError({ + // statusCode: 401, + // statusMessage: 'Authentication required' + // }) + // } + + // const config = useRuntimeConfig() + // const decoded = jwt.verify(token, config.jwtSecret) + + const body = await readBody(event) + + // Validate required fields + if (!body.title || !body.description || !body.startDate || !body.endDate) { + throw createError({ + statusCode: 400, + statusMessage: 'Missing required fields' + }) + } + + await connectDB() + + const newEvent = new Event({ + ...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 + }) + + const savedEvent = await newEvent.save() + + return savedEvent + } catch (error) { + console.error('Error creating event:', error) + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to create event' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/events/[id].delete.js b/server/api/admin/events/[id].delete.js new file mode 100644 index 0000000..e283859 --- /dev/null +++ b/server/api/admin/events/[id].delete.js @@ -0,0 +1,41 @@ +import Event from '../../../models/event.js' +import { connectDB } from '../../../utils/mongoose.js' +import jwt from 'jsonwebtoken' + +export default defineEventHandler(async (event) => { + try { + // TODO: Temporarily disabled auth for testing - enable when authentication is set up + // const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '') + + // if (!token) { + // throw createError({ + // statusCode: 401, + // statusMessage: 'Authentication required' + // }) + // } + + // const config = useRuntimeConfig() + // const decoded = jwt.verify(token, config.jwtSecret) + + const eventId = getRouterParam(event, 'id') + + await connectDB() + + const deletedEvent = await Event.findByIdAndDelete(eventId) + + if (!deletedEvent) { + throw createError({ + statusCode: 404, + statusMessage: 'Event not found' + }) + } + + return { success: true, message: 'Event deleted successfully' } + } catch (error) { + console.error('Error deleting event:', error) + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to delete event' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/events/[id].get.js b/server/api/admin/events/[id].get.js new file mode 100644 index 0000000..cd1bf54 --- /dev/null +++ b/server/api/admin/events/[id].get.js @@ -0,0 +1,47 @@ +import Event from '../../../models/event.js' +import { connectDB } from '../../../utils/mongoose.js' +import jwt from 'jsonwebtoken' + +export default defineEventHandler(async (event) => { + try { + // TODO: Temporarily disabled auth for testing - enable when authentication is set up + // const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '') + + // if (!token) { + // throw createError({ + // statusCode: 401, + // statusMessage: 'Authentication required' + // }) + // } + + // const config = useRuntimeConfig() + // const decoded = jwt.verify(token, config.jwtSecret) + + const eventId = getRouterParam(event, 'id') + console.log('🔍 API: Get event by ID called') + console.log('🔍 API: Event ID param:', eventId) + + await connectDB() + + const eventData = await Event.findById(eventId) + console.log('🔍 API: Event data found:', eventData ? 'YES' : 'NO') + console.log('🔍 API: Event data preview:', eventData ? { id: eventData._id, title: eventData.title } : null) + + if (!eventData) { + console.log('❌ API: Event not found in database') + throw createError({ + statusCode: 404, + statusMessage: 'Event not found' + }) + } + + console.log('✅ API: Returning event data') + return { data: eventData } + } catch (error) { + console.error('❌ API: Error fetching event:', error) + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to fetch event' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/events/[id].put.js b/server/api/admin/events/[id].put.js new file mode 100644 index 0000000..2873689 --- /dev/null +++ b/server/api/admin/events/[id].put.js @@ -0,0 +1,62 @@ +import Event from '../../../models/event.js' +import { connectDB } from '../../../utils/mongoose.js' +import jwt from 'jsonwebtoken' + +export default defineEventHandler(async (event) => { + try { + // TODO: Temporarily disabled auth for testing - enable when authentication is set up + // const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '') + + // if (!token) { + // throw createError({ + // statusCode: 401, + // statusMessage: 'Authentication required' + // }) + // } + + // const config = useRuntimeConfig() + // const decoded = jwt.verify(token, config.jwtSecret) + + const eventId = getRouterParam(event, 'id') + const body = await readBody(event) + + // Validate required fields + if (!body.title || !body.description || !body.startDate || !body.endDate) { + throw createError({ + statusCode: 400, + statusMessage: 'Missing required fields' + }) + } + + await connectDB() + + const updateData = { + ...body, + startDate: new Date(body.startDate), + endDate: new Date(body.endDate), + registrationDeadline: body.registrationDeadline ? new Date(body.registrationDeadline) : null, + updatedAt: new Date() + } + + const updatedEvent = await Event.findByIdAndUpdate( + eventId, + updateData, + { new: true, runValidators: true } + ) + + if (!updatedEvent) { + throw createError({ + statusCode: 404, + statusMessage: 'Event not found' + }) + } + + return updatedEvent + } catch (error) { + console.error('Error updating event:', error) + throw createError({ + statusCode: 500, + statusMessage: error.message || 'Failed to update event' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/members.get.js b/server/api/admin/members.get.js new file mode 100644 index 0000000..3e8bccc --- /dev/null +++ b/server/api/admin/members.get.js @@ -0,0 +1,34 @@ +import Member from '../../models/member.js' +import { connectDB } from '../../utils/mongoose.js' +import jwt from 'jsonwebtoken' + +export default defineEventHandler(async (event) => { + try { + // TODO: Temporarily disabled auth for testing - enable when authentication is set up + // Basic auth check - you may want to implement proper admin role checking + // const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '') + + // if (!token) { + // throw createError({ + // statusCode: 401, + // statusMessage: 'Authentication required' + // }) + // } + + // const config = useRuntimeConfig() + // jwt.verify(token, config.jwtSecret) + + await connectDB() + + const members = await Member.find() + .sort({ createdAt: -1 }) + .lean() + + return members + } catch (error) { + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch members' + }) + } +}) \ No newline at end of file diff --git a/server/api/admin/members.post.js b/server/api/admin/members.post.js new file mode 100644 index 0000000..cb9f139 --- /dev/null +++ b/server/api/admin/members.post.js @@ -0,0 +1,60 @@ +import Member from '../../models/member.js' +import { connectDB } from '../../utils/mongoose.js' +import jwt from 'jsonwebtoken' + +export default defineEventHandler(async (event) => { + try { + // TODO: Temporarily disabled auth for testing - enable when authentication is set up + // const token = getCookie(event, 'auth-token') || getHeader(event, 'authorization')?.replace('Bearer ', '') + + // if (!token) { + // throw createError({ + // statusCode: 401, + // statusMessage: 'Authentication required' + // }) + // } + + // const config = useRuntimeConfig() + // jwt.verify(token, config.jwtSecret) + + const body = await readBody(event) + + // Validate required fields + if (!body.name || !body.email || !body.circle || !body.contributionTier) { + throw createError({ + statusCode: 400, + statusMessage: 'Missing required fields' + }) + } + + await connectDB() + + // Check if member already exists + const existingMember = await Member.findOne({ email: body.email }) + if (existingMember) { + throw createError({ + statusCode: 409, + statusMessage: 'Member with this email already exists' + }) + } + + const newMember = new Member({ + name: body.name, + email: body.email, + circle: body.circle, + contributionTier: body.contributionTier, + slackInvited: false + }) + + const savedMember = await newMember.save() + + return savedMember + } catch (error) { + if (error.statusCode) throw error + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to create member' + }) + } +}) \ No newline at end of file diff --git a/server/api/auth/login.post.js b/server/api/auth/login.post.js index 2737410..27d07b5 100644 --- a/server/api/auth/login.post.js +++ b/server/api/auth/login.post.js @@ -1,32 +1,76 @@ // server/api/auth/login.post.js import jwt from 'jsonwebtoken' -import Member from '../../models/member' +import { Resend } from 'resend' +import Member from '../../models/member.js' +import { connectDB } from '../../utils/mongoose.js' + +const resend = new Resend(process.env.RESEND_API_KEY) export default defineEventHandler(async (event) => { + // Connect to database + await connectDB() + const { email } = await readBody(event) + if (!email) { + throw createError({ + statusCode: 400, + statusMessage: 'Email is required' + }) + } + const member = await Member.findOne({ email }) if (!member) { - throw createError({ statusCode: 404 }) + throw createError({ + statusCode: 404, + statusMessage: 'No account found with that email address' + }) } - // Send magic link via Resend + // Generate magic link token const token = jwt.sign( { memberId: member._id }, process.env.JWT_SECRET, - { expiresIn: '7d' } + { expiresIn: '15m' } // Shorter expiry for security ) - await resend.emails.send({ - from: 'Ghost Guild ', - to: email, - subject: 'Your Ghost Guild login link', - html: ` - - Click here to log in - - ` - }) + // Get the base URL for the magic link + const headers = getHeaders(event) + const baseUrl = process.env.BASE_URL || `${headers.host?.includes('localhost') ? 'http' : 'https'}://${headers.host}` - return { success: true } + // Send magic link via Resend + try { + await resend.emails.send({ + from: 'Ghost Guild ', + to: email, + subject: 'Your Ghost Guild login link', + html: ` +
+

Welcome back to Ghost Guild!

+

Click the button below to sign in to your account:

+ +

+ This link will expire in 15 minutes for security. If you didn't request this login link, you can safely ignore this email. +

+
+ ` + }) + + return { + success: true, + message: 'Login link sent to your email' + } + + } catch (error) { + console.error('Failed to send email:', error) + throw createError({ + statusCode: 500, + statusMessage: 'Failed to send login email. Please try again.' + }) + } }) \ No newline at end of file diff --git a/server/api/auth/logout.post.js b/server/api/auth/logout.post.js new file mode 100644 index 0000000..87f6065 --- /dev/null +++ b/server/api/auth/logout.post.js @@ -0,0 +1,11 @@ +export default defineEventHandler(async (event) => { + // Clear the auth token cookie + setCookie(event, 'auth-token', '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 0 // Expire immediately + }) + + return { message: 'Logged out successfully' } +}) \ No newline at end of file diff --git a/server/api/auth/verify.get.js b/server/api/auth/verify.get.js new file mode 100644 index 0000000..b2eee10 --- /dev/null +++ b/server/api/auth/verify.get.js @@ -0,0 +1,57 @@ +// server/api/auth/verify.get.js +import jwt from 'jsonwebtoken' +import Member from '../../models/member.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + // Connect to database + await connectDB() + + const query = getQuery(event) + const { token } = query + + if (!token) { + throw createError({ + statusCode: 400, + statusMessage: 'Token is required' + }) + } + + try { + // Verify the JWT token + const decoded = jwt.verify(token, process.env.JWT_SECRET) + const member = await Member.findById(decoded.memberId) + + if (!member) { + throw createError({ + statusCode: 404, + statusMessage: 'Member not found' + }) + } + + // Create a new session token for the authenticated user + const sessionToken = jwt.sign( + { memberId: member._id, email: member.email }, + process.env.JWT_SECRET, + { expiresIn: '30d' } + ) + + // Set the session cookie + setCookie(event, 'auth-token', sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30 // 30 days + }) + + // Redirect to the members dashboard or home page + await sendRedirect(event, '/members', 302) + + } 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/events/[id].get.js b/server/api/events/[id].get.js new file mode 100644 index 0000000..f0822ba --- /dev/null +++ b/server/api/events/[id].get.js @@ -0,0 +1,65 @@ +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' +import mongoose from 'mongoose' + +export default defineEventHandler(async (event) => { + try { + // Ensure database connection + await connectDB() + const identifier = getRouterParam(event, 'id') + + if (!identifier) { + throw createError({ + statusCode: 400, + statusMessage: 'Event identifier is required' + }) + } + + // Fetch event from database - try by slug first, then by ID + let eventData + + // Check if identifier is a valid MongoDB ObjectId + if (mongoose.Types.ObjectId.isValid(identifier)) { + eventData = await Event.findById(identifier) + .select('-registrations.email') // Hide emails for privacy + .lean() + } + + // If not found by ID or not a valid ObjectId, try by slug + if (!eventData) { + eventData = await Event.findOne({ slug: identifier }) + .select('-registrations.email') // Hide emails for privacy + .lean() + } + + if (!eventData) { + throw createError({ + statusCode: 404, + statusMessage: 'Event not found' + }) + } + + // Add computed fields + const eventWithMeta = { + ...eventData, + id: eventData._id.toString(), + registeredCount: eventData.registrations?.length || 0, + isFull: eventData.maxAttendees ? + (eventData.registrations?.length || 0) >= eventData.maxAttendees : + false + } + + return eventWithMeta + } catch (error) { + console.error('Error fetching event:', error) + + if (error.statusCode) { + throw error + } + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch event' + }) + } +}) \ No newline at end of file diff --git a/server/api/events/[id]/register.post.js b/server/api/events/[id]/register.post.js new file mode 100644 index 0000000..618f81f --- /dev/null +++ b/server/api/events/[id]/register.post.js @@ -0,0 +1,116 @@ +import Event from '../../../models/event.js' +import Member from '../../../models/member.js' +import { connectDB } from '../../../utils/mongoose.js' +import mongoose from 'mongoose' + +export default defineEventHandler(async (event) => { + try { + // Ensure database connection + 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 + if (!body.name || !body.email) { + throw createError({ + statusCode: 400, + statusMessage: 'Name and email are required' + }) + } + + // Fetch the event - try by slug first, then by ID + let eventData + + // Check if identifier is a valid MongoDB ObjectId + if (mongoose.Types.ObjectId.isValid(identifier)) { + eventData = await Event.findById(identifier) + } + + // If not found by ID or not a valid ObjectId, try by slug + if (!eventData) { + eventData = await Event.findOne({ slug: identifier }) + } + + if (!eventData) { + throw createError({ + statusCode: 404, + statusMessage: 'Event not found' + }) + } + + // 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' + }) + } + + // 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}` + } + + // Add registration + eventData.registrations.push({ + name: body.name, + email: body.email.toLowerCase(), + membershipLevel: body.membershipLevel || 'non-member', + dietary: body.dietary || false, + registeredAt: new Date() + }) + + // Save the updated event + await eventData.save() + + // TODO: Send confirmation email using Resend + // await sendEventRegistrationEmail(body.email, eventData) + + return { + success: true, + message: 'Successfully registered for the event', + registrationId: eventData.registrations[eventData.registrations.length - 1]._id + } + } catch (error) { + console.error('Error registering for event:', error) + + if (error.statusCode) { + throw error + } + + throw createError({ + statusCode: 500, + statusMessage: 'Failed to register for event' + }) + } +}) \ No newline at end of file diff --git a/server/api/events/index.get.js b/server/api/events/index.get.js new file mode 100644 index 0000000..3416a53 --- /dev/null +++ b/server/api/events/index.get.js @@ -0,0 +1,53 @@ +import Event from '../../models/event.js' +import { connectDB } from '../../utils/mongoose.js' + +export default defineEventHandler(async (event) => { + try { + // Ensure database connection + await connectDB() + // Get query parameters for filtering + const query = getQuery(event) + const filter = {} + + // Only show visible events on public calendar (unless specifically requested) + if (query.includeHidden !== 'true') { + filter.isVisible = true + } + + // Filter for upcoming events only if requested + if (query.upcoming === 'true') { + filter.startDate = { $gte: new Date() } + } + + // Filter by event type if provided + if (query.eventType) { + filter.eventType = query.eventType + } + + // Filter for members-only events + if (query.membersOnly !== undefined) { + filter.membersOnly = query.membersOnly === 'true' + } + + // Fetch events from database + const events = await Event.find(filter) + .sort({ startDate: 1 }) + .select('-registrations') // Don't expose registration details in list view + .lean() + + // Add computed fields + const eventsWithMeta = events.map(event => ({ + ...event, + id: event._id.toString(), + registeredCount: event.registrations?.length || 0 + })) + + return eventsWithMeta + } catch (error) { + console.error('Error fetching events:', error) + throw createError({ + statusCode: 500, + statusMessage: 'Failed to fetch events' + }) + } +}) \ No newline at end of file diff --git a/server/api/members/create.post.js b/server/api/members/create.post.js index bce2f64..b7828ad 100644 --- a/server/api/members/create.post.js +++ b/server/api/members/create.post.js @@ -1,14 +1,42 @@ // server/api/members/create.post.js -import Member from '../../models/member' +import Member from '../../models/member.js' +import { connectDB } from '../../utils/mongoose.js' +// Simple payment check function to avoid import issues +const requiresPayment = (contributionValue) => contributionValue !== '0' export default defineEventHandler(async (event) => { + // Ensure database is connected + await connectDB() + const body = await readBody(event) try { + // 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' + }) + } + const member = new Member(body) await member.save() + + // TODO: Process payment with Helcim if not free tier + if (requiresPayment(body.contributionTier)) { + // Payment processing will be added here + console.log('Payment processing needed for tier:', body.contributionTier) + } + + // TODO: Send welcome email + console.log('Welcome email should be sent to:', body.email) + return { success: true, member } } catch (error) { + if (error.statusCode) { + throw error + } throw createError({ statusCode: 400, statusMessage: error.message diff --git a/server/api/upload/image.post.js b/server/api/upload/image.post.js new file mode 100644 index 0000000..b9fb9d5 --- /dev/null +++ b/server/api/upload/image.post.js @@ -0,0 +1,72 @@ +import { v2 as cloudinary } from 'cloudinary' + +// Configure Cloudinary +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET +}) + +export default defineEventHandler(async (event) => { + try { + // Parse the multipart form data + const formData = await readMultipartFormData(event) + + if (!formData || formData.length === 0) { + throw createError({ + statusCode: 400, + statusMessage: 'No file provided' + }) + } + + // Find the file in the form data + const fileData = formData.find(item => item.name === 'file') + + if (!fileData) { + throw createError({ + statusCode: 400, + statusMessage: 'No file found in upload' + }) + } + + // Validate file type + if (!fileData.type?.startsWith('image/')) { + throw createError({ + statusCode: 400, + statusMessage: 'Invalid file type. Only images are allowed.' + }) + } + + // Convert buffer to base64 for Cloudinary upload + const base64File = `data:${fileData.type};base64,${fileData.data.toString('base64')}` + + // Upload to Cloudinary + const result = await cloudinary.uploader.upload(base64File, { + folder: 'ghost-guild/events', + transformation: [ + { quality: 'auto', fetch_format: 'auto' }, + { width: 1200, height: 630, crop: 'fill' } // Standard social media dimensions + ], + allowed_formats: ['jpg', 'png', 'webp', 'gif'], + resource_type: 'image' + }) + + return { + success: true, + secure_url: result.secure_url, + public_id: result.public_id, + width: result.width, + height: result.height, + format: result.format, + bytes: result.bytes + } + + } catch (error) { + console.error('Image upload error:', error) + + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.statusMessage || 'Image upload failed' + }) + } +}) \ No newline at end of file diff --git a/server/models/event.js b/server/models/event.js new file mode 100644 index 0000000..312f5ca --- /dev/null +++ b/server/models/event.js @@ -0,0 +1,99 @@ +import mongoose from 'mongoose' + +const eventSchema = new mongoose.Schema({ + title: { type: String, required: true }, + slug: { type: String, required: true, unique: true }, + tagline: String, + description: { type: String, required: true }, + content: String, + featureImage: { + url: String, // Cloudinary URL + publicId: String, // Cloudinary public ID for transformations + alt: String // Alt text for accessibility + }, + startDate: { type: Date, required: true }, + endDate: { type: Date, required: true }, + eventType: { + type: String, + enum: ['community', 'workshop', 'social', 'showcase'], + default: 'community' + }, + // Online-first location handling + location: { + type: String, + required: true, + // This will typically be a Slack channel or video conference link + validate: { + validator: function(v) { + // Must be either a valid URL or a Slack channel reference + const urlPattern = /^https?:\/\/.+/; + const slackPattern = /^#[a-zA-Z0-9-_]+$/; + return urlPattern.test(v) || slackPattern.test(v); + }, + message: 'Location must be a valid URL (video conference link) or Slack channel (starting with #)' + } + }, + isOnline: { type: Boolean, default: true }, // Default to online-first + // Visibility and status controls + isVisible: { type: Boolean, default: true }, // Hide from public calendar when false + isCancelled: { type: Boolean, default: false }, + cancellationMessage: String, // Custom message for cancelled events + membersOnly: { type: Boolean, default: false }, + // Circle targeting + targetCircles: [{ + type: String, + enum: ['community', 'founder', 'practitioner'], + required: false + }], + maxAttendees: Number, + registrationRequired: { type: Boolean, default: false }, + registrationDeadline: Date, + agenda: [String], + speakers: [{ + name: String, + role: String, + bio: String + }], + registrations: [{ + name: String, + email: String, + membershipLevel: String, + registeredAt: { type: Date, default: Date.now } + }], + createdBy: { type: String, required: true }, + createdAt: { type: Date, default: Date.now }, + updatedAt: { type: Date, default: Date.now } +}) + +// Generate slug from title +function generateSlug(title) { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +// Pre-save hook to generate slug +eventSchema.pre('save', async function(next) { + try { + if (this.isNew || this.isModified('title')) { + let baseSlug = generateSlug(this.title) + let slug = baseSlug + let counter = 1 + + // Ensure slug is unique + while (await this.constructor.findOne({ slug, _id: { $ne: this._id } })) { + slug = `${baseSlug}-${counter}` + counter++ + } + + this.slug = slug + } + next() + } catch (error) { + console.error('Error in pre-save hook:', error) + next(error) + } +}) + +export default mongoose.models.Event || mongoose.model('Event', eventSchema) \ No newline at end of file diff --git a/server/models/member.js b/server/models/member.js index 3f4962e..54d51e8 100644 --- a/server/models/member.js +++ b/server/models/member.js @@ -1,17 +1,25 @@ // server/models/member.js import mongoose from 'mongoose' +import { resolve } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = fileURLToPath(new URL('.', import.meta.url)) + +// Import configs using dynamic imports to avoid build issues +const getValidCircleValues = () => ['community', 'founder', 'practitioner'] +const getValidContributionValues = () => ['0', '5', '15', '30', '50'] const memberSchema = new mongoose.Schema({ email: { type: String, required: true, unique: true }, name: { type: String, required: true }, circle: { type: String, - enum: ['community', 'founder', 'practitioner'], + enum: getValidCircleValues(), required: true }, contributionTier: { type: String, - enum: ['0', '5', '15', '30', '50'], + enum: getValidContributionValues(), required: true }, helcimCustomerId: String, diff --git a/server/utils/helcim.js b/server/utils/helcim.js new file mode 100644 index 0000000..c9bc70b --- /dev/null +++ b/server/utils/helcim.js @@ -0,0 +1,140 @@ +// Helcim Payment Integration Utilities + +export const processHelcimPayment = async (paymentData) => { + const { amount, paymentToken, customerData } = paymentData; + + // Check if Helcim is configured + const helcimAccountId = process.env.HELCIM_ACCOUNT_ID; + const helcimApiToken = process.env.HELCIM_API_TOKEN; + + if (!helcimAccountId || !helcimApiToken) { + console.warn('Helcim not configured - skipping payment processing'); + return { + success: false, + message: 'Payment processing not configured', + testMode: true + }; + } + + try { + // In production, you would make API calls to Helcim here + // Example structure: + const response = await fetch('https://api.helcim.com/v2/payment/purchase', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'api-token': helcimApiToken, + 'account-id': helcimAccountId + }, + body: JSON.stringify({ + amount, + currency: 'CAD', + paymentToken, + customerCode: customerData.email, + contactName: customerData.name, + billingAddress: { + contactName: customerData.name, + email: customerData.email + } + }) + }); + + const result = await response.json(); + + return { + success: result.success || false, + transactionId: result.transactionId, + customerId: result.customerCode, + message: result.message + }; + } catch (error) { + console.error('Helcim payment error:', error); + return { + success: false, + message: error.message || 'Payment processing failed' + }; + } +}; + +export const createHelcimSubscription = async (subscriptionData) => { + const { customerId, planId, amount } = subscriptionData; + + const helcimAccountId = process.env.HELCIM_ACCOUNT_ID; + const helcimApiToken = process.env.HELCIM_API_TOKEN; + + if (!helcimAccountId || !helcimApiToken) { + console.warn('Helcim not configured - skipping subscription creation'); + return { + success: false, + message: 'Subscription processing not configured', + testMode: true + }; + } + + try { + // Create recurring payment plan + const response = await fetch('https://api.helcim.com/v2/payment/plan', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'api-token': helcimApiToken, + 'account-id': helcimAccountId + }, + body: JSON.stringify({ + customerCode: customerId, + planName: `Ghost Guild ${planId}`, + amount, + currency: 'CAD', + frequency: 'MONTHLY', + startDate: new Date().toISOString().split('T')[0] + }) + }); + + const result = await response.json(); + + return { + success: result.success || false, + subscriptionId: result.planId, + message: result.message + }; + } catch (error) { + console.error('Helcim subscription error:', error); + return { + success: false, + message: error.message || 'Subscription creation failed' + }; + } +}; + +export const cancelHelcimSubscription = async (subscriptionId) => { + const helcimApiToken = process.env.HELCIM_API_TOKEN; + + if (!helcimApiToken) { + return { + success: false, + message: 'Subscription management not configured' + }; + } + + try { + const response = await fetch(`https://api.helcim.com/v2/payment/plan/${subscriptionId}/cancel`, { + method: 'POST', + headers: { + 'api-token': helcimApiToken + } + }); + + const result = await response.json(); + + return { + success: result.success || false, + message: result.message + }; + } catch (error) { + console.error('Helcim cancellation error:', error); + return { + success: false, + message: error.message || 'Subscription cancellation failed' + }; + } +}; \ No newline at end of file diff --git a/server/utils/mongoose.js b/server/utils/mongoose.js new file mode 100644 index 0000000..6456191 --- /dev/null +++ b/server/utils/mongoose.js @@ -0,0 +1,24 @@ +import mongoose from 'mongoose'; + +let isConnected = false; + +export const connectDB = async () => { + if (isConnected) { + return; + } + + const MONGODB_URI = process.env.NUXT_MONGODB_URI || process.env.MONGODB_URI || 'mongodb://localhost:27017/ghostguild'; + + try { + await mongoose.connect(MONGODB_URI, { + serverSelectionTimeoutMS: 5000, + }); + isConnected = true; + console.log('MongoDB connected successfully'); + } catch (error) { + console.error('MongoDB connection error:', error); + throw error; + } +}; + +export default connectDB; \ No newline at end of file From c3a29fa47c646e0f5b1581eeee5e41db8b15392c Mon Sep 17 00:00:00 2001 From: Jennie Robinson Faber Date: Wed, 27 Aug 2025 17:14:02 +0100 Subject: [PATCH 2/2] Refactor event creation and display: Remove debug logs from event creation page, enhance layout for better responsiveness, and implement image URL fallback logic in event detail and index pages. Improve error handling for image loading. --- app/pages/admin/events/create.vue | 34 +++---------------------------- app/pages/admin/events/index.vue | 17 ++-------------- app/pages/events/[id].vue | 30 +++++++++++++++++++++++---- app/pages/events/index.vue | 26 ++++++++++++++++++++++- 4 files changed, 56 insertions(+), 51 deletions(-) diff --git a/app/pages/admin/events/create.vue b/app/pages/admin/events/create.vue index 4c4faf7..299517a 100644 --- a/app/pages/admin/events/create.vue +++ b/app/pages/admin/events/create.vue @@ -10,19 +10,13 @@

{{ editingEvent ? 'Edit Event' : 'Create New Event' }}

-
- DEBUG: Create page loaded successfully! -
- Route query: {{ JSON.stringify($route.query) }} -
-

Fill out the form below to create or update an event

-
+
@@ -48,7 +42,7 @@
-
+

Basic Information

@@ -348,20 +342,13 @@ \ No newline at end of file diff --git a/app/pages/events/[id].vue b/app/pages/events/[id].vue index 6b90e6a..ee3d3b7 100644 --- a/app/pages/events/[id].vue +++ b/app/pages/events/[id].vue @@ -21,13 +21,12 @@
-
+
@@ -366,6 +365,29 @@ const getOptimizedImageUrl = (publicId, transformations) => { return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}` } +// Get image URL with fallback logic +const getImageUrl = (featureImage) => { + if (!featureImage) return '' + + // If we have a direct URL, use it as primary (since seed data uses external URLs) + if (featureImage.url) { + return featureImage.url + } + + // Fallback to Cloudinary if we have a publicId + if (featureImage.publicId) { + return getOptimizedImageUrl(featureImage.publicId, 'w_1200,h_400,c_fill') + } + + return '' +} + +// Handle image loading errors +const handleImageError = (event) => { + console.warn('Image failed to load:', event.target.src) + // Optionally hide the image container or show a placeholder +} + // Handle registration submission const handleRegistration = async () => { isRegistering.value = true diff --git a/app/pages/events/index.vue b/app/pages/events/index.vue index dcbd7b1..5c23b52 100644 --- a/app/pages/events/index.vue +++ b/app/pages/events/index.vue @@ -85,9 +85,10 @@
@@ -325,6 +326,29 @@ const getOptimizedImageUrl = (publicId, transformations) => { return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}` } +// Get image URL with fallback logic +const getImageUrl = (featureImage) => { + if (!featureImage) return '' + + // If we have a direct URL, use it as primary (since seed data uses external URLs) + if (featureImage.url) { + return featureImage.url + } + + // Fallback to Cloudinary if we have a publicId + if (featureImage.publicId) { + return getOptimizedImageUrl(featureImage.publicId, 'w_400,h_200,c_fill') + } + + return '' +} + +// Handle image loading errors +const handleImageError = (event) => { + console.warn('Image failed to load:', event.target.src) + // Optionally hide the image container or show a placeholder +} + // Handle calendar event click const onEventClick = (event) => { if (event.id) {