Compare commits
3 commits
2b55ca4104
...
fb02688166
| Author | SHA1 | Date | |
|---|---|---|---|
| fb02688166 | |||
| 970b185151 | |||
| 1b8dacf92a |
39 changed files with 2267 additions and 1960 deletions
|
|
@ -6,8 +6,13 @@ export default defineAppConfig({
|
|||
},
|
||||
formField: {
|
||||
slots: {
|
||||
label: "block font-medium text-stone-200",
|
||||
label: "block font-medium text-[--ui-text-dimmed]",
|
||||
},
|
||||
},
|
||||
},
|
||||
colorMode: {
|
||||
preference: "system", // default value of $colorMode.preference
|
||||
fallback: "dark", // fallback value if not system preference found
|
||||
classSuffix: "", // default is '', set to '-mode' to have 'dark-mode' and 'light-mode'
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,14 +2,52 @@
|
|||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
@theme static {
|
||||
@theme {
|
||||
/* Font families */
|
||||
--font-sans: "Inter", sans-serif;
|
||||
--font-body: "Inter", sans-serif;
|
||||
--font-mono: "Ubuntu Mono", monospace;
|
||||
--font-display: "NB Television Pro", monospace;
|
||||
|
||||
/* Ethereal color palette - grays, blacks, minimal color */
|
||||
/* Ethereal color palette - light mode (inverted for light backgrounds) */
|
||||
--color-ghost-50: #0a0a0a;
|
||||
--color-ghost-100: #1a1a1a;
|
||||
--color-ghost-200: #2a2a2a;
|
||||
--color-ghost-300: #3a3a3a;
|
||||
--color-ghost-400: #4a4a4a;
|
||||
--color-ghost-500: #6a6a6a;
|
||||
--color-ghost-600: #8a8a8a;
|
||||
--color-ghost-700: #b0b0b0;
|
||||
--color-ghost-800: #d0d0d0;
|
||||
--color-ghost-900: #f0f0f0;
|
||||
|
||||
/* Subtle accent - barely visible blue-gray (light mode) */
|
||||
--color-whisper-50: #0f1419;
|
||||
--color-whisper-100: #1a1f2e;
|
||||
--color-whisper-200: #252d40;
|
||||
--color-whisper-300: #2f3b52;
|
||||
--color-whisper-400: #3a4964;
|
||||
--color-whisper-500: #4f5d7a;
|
||||
--color-whisper-600: #687291;
|
||||
--color-whisper-700: #8491a8;
|
||||
--color-whisper-800: #a8b3c7;
|
||||
--color-whisper-900: #d4dae6;
|
||||
|
||||
/* Sparkle accent (light mode) */
|
||||
--color-sparkle-50: #202020;
|
||||
--color-sparkle-100: #404040;
|
||||
--color-sparkle-200: #606060;
|
||||
--color-sparkle-300: #808080;
|
||||
--color-sparkle-400: #a0a0a0;
|
||||
--color-sparkle-500: #c0c0c0;
|
||||
--color-sparkle-600: #d0d0d0;
|
||||
--color-sparkle-700: #e8e8e8;
|
||||
--color-sparkle-800: #f0f0f0;
|
||||
--color-sparkle-900: #fafafa;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Ethereal color palette - dark mode (original values) */
|
||||
--color-ghost-50: #f0f0f0;
|
||||
--color-ghost-100: #d0d0d0;
|
||||
--color-ghost-200: #b0b0b0;
|
||||
|
|
@ -21,7 +59,7 @@
|
|||
--color-ghost-800: #1a1a1a;
|
||||
--color-ghost-900: #0a0a0a;
|
||||
|
||||
/* Subtle accent - barely visible blue-gray */
|
||||
/* Subtle accent - barely visible blue-gray (dark mode) */
|
||||
--color-whisper-50: #d4dae6;
|
||||
--color-whisper-100: #a8b3c7;
|
||||
--color-whisper-200: #8491a8;
|
||||
|
|
@ -33,7 +71,7 @@
|
|||
--color-whisper-800: #1a1f2e;
|
||||
--color-whisper-900: #0f1419;
|
||||
|
||||
/* Sparkle accent */
|
||||
/* Sparkle accent (dark mode) */
|
||||
--color-sparkle-50: #fafafa;
|
||||
--color-sparkle-100: #f0f0f0;
|
||||
--color-sparkle-200: #e8e8e8;
|
||||
|
|
@ -46,33 +84,79 @@
|
|||
--color-sparkle-900: #202020;
|
||||
}
|
||||
|
||||
/* Global ethereal background */
|
||||
/* Global ethereal background - light mode */
|
||||
:root {
|
||||
--ethereal-bg: radial-gradient(circle at 20% 80%, rgba(232, 232, 232, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(232, 232, 232, 0.02) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(232, 232, 232, 0.01) 0%, transparent 50%);
|
||||
--ethereal-bg:
|
||||
radial-gradient(
|
||||
circle at 20% 80%,
|
||||
rgba(40, 40, 40, 0.03) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 20%,
|
||||
rgba(40, 40, 40, 0.02) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 40% 40%,
|
||||
rgba(40, 40, 40, 0.01) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
|
||||
--halftone-pattern: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px);
|
||||
--halftone-pattern: radial-gradient(
|
||||
circle,
|
||||
rgba(0, 0, 0, 0.1) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
--halftone-size: 8px 8px;
|
||||
}
|
||||
|
||||
/* Dark mode background */
|
||||
.dark:root {
|
||||
--ethereal-bg:
|
||||
radial-gradient(
|
||||
circle at 20% 80%,
|
||||
rgba(232, 232, 232, 0.03) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 80% 20%,
|
||||
rgba(232, 232, 232, 0.02) 0%,
|
||||
transparent 50%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 40% 40%,
|
||||
rgba(232, 232, 232, 0.01) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
|
||||
--halftone-pattern: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 255, 255, 0.1) 1px,
|
||||
transparent 1px
|
||||
);
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--color-ghost-900);
|
||||
color: var(--color-ghost-200);
|
||||
@apply text-[--ui-text];
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--ethereal-bg), var(--color-ghost-900);
|
||||
background: var(--ethereal-bg), #f0f0f0;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
background: var(--ethereal-bg), #0a0a0a;
|
||||
}
|
||||
|
||||
/* Halftone texture overlay */
|
||||
.halftone-texture {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.halftone-texture::before {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
|
|
@ -86,14 +170,28 @@ body {
|
|||
|
||||
/* Sparkle effects */
|
||||
@keyframes sparkle {
|
||||
0%, 100% { opacity: 0.3; transform: scale(0.8); }
|
||||
50% { opacity: 1; transform: scale(1.2); }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.2);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 0.2; }
|
||||
25% { opacity: 0.8; }
|
||||
75% { opacity: 0.4; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
75% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.sparkle-field {
|
||||
|
|
@ -102,20 +200,50 @@ body {
|
|||
}
|
||||
|
||||
.sparkle-field::after {
|
||||
content: '';
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-image:
|
||||
radial-gradient(circle at 10% 20%, var(--color-sparkle-200) 1px, transparent 1px),
|
||||
radial-gradient(circle at 90% 80%, var(--color-sparkle-400) 1px, transparent 1px),
|
||||
radial-gradient(circle at 30% 70%, var(--color-sparkle-200) 0.5px, transparent 0.5px),
|
||||
radial-gradient(circle at 70% 30%, var(--color-sparkle-400) 0.5px, transparent 0.5px),
|
||||
radial-gradient(circle at 50% 10%, var(--color-sparkle-200) 1px, transparent 1px),
|
||||
radial-gradient(circle at 20% 90%, var(--color-sparkle-400) 0.5px, transparent 0.5px);
|
||||
background-size: 200px 200px, 300px 300px, 150px 150px, 250px 250px, 180px 180px, 220px 220px;
|
||||
radial-gradient(
|
||||
circle at 10% 20%,
|
||||
var(--color-sparkle-200) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 90% 80%,
|
||||
var(--color-sparkle-400) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 30% 70%,
|
||||
var(--color-sparkle-200) 0.5px,
|
||||
transparent 0.5px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 70% 30%,
|
||||
var(--color-sparkle-400) 0.5px,
|
||||
transparent 0.5px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 50% 10%,
|
||||
var(--color-sparkle-200) 1px,
|
||||
transparent 1px
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 20% 90%,
|
||||
var(--color-sparkle-400) 0.5px,
|
||||
transparent 0.5px
|
||||
);
|
||||
background-size:
|
||||
200px 200px,
|
||||
300px 300px,
|
||||
150px 150px,
|
||||
250px 250px,
|
||||
180px 180px,
|
||||
220px 220px;
|
||||
animation: twinkle 4s infinite ease-in-out;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
|
|
@ -141,6 +269,22 @@ body {
|
|||
linear-gradient(45deg, transparent 75%, var(--color-ghost-700) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--color-ghost-700) 75%);
|
||||
background-size: 4px 4px;
|
||||
background-position: 0 0, 0 2px, 2px -2px, -2px 0px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 2px,
|
||||
2px -2px,
|
||||
-2px 0px;
|
||||
}
|
||||
|
||||
/* Mobile responsive utilities */
|
||||
@media (max-width: 1023px) {
|
||||
/* Prevent horizontal scroll on mobile */
|
||||
body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Adjust halftone pattern for mobile */
|
||||
.halftone-texture::before {
|
||||
background-size: 6px 6px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
>
|
||||
<!-- Left: Copyright and minimal info -->
|
||||
<div>
|
||||
<p class="text-stone-500 text-xs mb-2">
|
||||
<p class="text-ghost-500 text-xs mb-2">
|
||||
© {{ currentYear }} Ghost Guild
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
<div class="flex flex-wrap gap-6 text-xs">
|
||||
<a
|
||||
href="mailto:hello@ghostguild.org"
|
||||
class="text-stone-500 hover:text-stone-300 transition-colors"
|
||||
class="text-ghost-500 hover:text-ghost-300 transition-colors"
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,38 @@
|
|||
<template>
|
||||
<nav
|
||||
class="w-64 lg:w-80 backdrop-blur-sm h-screen sticky top-0 flex flex-col"
|
||||
:class="[
|
||||
isMobile
|
||||
? 'w-full flex flex-col bg-transparent'
|
||||
: 'w-64 lg:w-80 backdrop-blur-sm h-screen sticky top-0 flex flex-col bg-ghost-900 border-r border-ghost-700',
|
||||
]"
|
||||
>
|
||||
<!-- Logo/Brand at top -->
|
||||
<div class="p-8 border-b border-ghost-800 bg-blue-400">
|
||||
<!-- Logo/Brand at top (desktop only) -->
|
||||
<div v-if="!isMobile" class="p-8 border-b border-ghost-700 bg-primary-500">
|
||||
<NuxtLink to="/" class="flex flex-col items-center gap-3 group">
|
||||
<span
|
||||
class="text-xl font-bold text-stone-100 ethereal-text tracking-wider"
|
||||
<span class="text-xl font-bold text-white ethereal-text tracking-wider"
|
||||
>Ghost Guild Logo</span
|
||||
>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Vertical Navigation -->
|
||||
<div class="flex-1 p-8 overflow-y-auto">
|
||||
<ul class="space-y-6">
|
||||
<div
|
||||
:class="
|
||||
isMobile ? 'flex-1 p-6 overflow-y-auto' : 'flex-1 p-8 overflow-y-auto'
|
||||
"
|
||||
>
|
||||
<ul :class="isMobile ? 'space-y-4' : 'space-y-6'">
|
||||
<li v-for="item in navigationItems" :key="item.path">
|
||||
<NuxtLink :to="item.path" class="block group relative">
|
||||
<NuxtLink
|
||||
:to="item.path"
|
||||
class="block group relative"
|
||||
@click="handleNavigate"
|
||||
>
|
||||
<!-- Hover indicator -->
|
||||
|
||||
<span
|
||||
class="text-stone-200 hover:text-stone-100 transition-all duration-300 text-lg tracking-wide block py-2 hover:ethereal-text"
|
||||
active-class="text-stone-100 ethereal-text translate-x-2"
|
||||
class="text-ghost-200 hover:text-ghost-100 transition-all duration-300 text-lg tracking-wide block py-2 hover:ethereal-text"
|
||||
active-class="text-ghost-100 ethereal-text translate-x-2"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
|
|
@ -29,12 +40,24 @@
|
|||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Color Mode Switcher -->
|
||||
<div class="mb-6">
|
||||
<UColorModeButton size="md" class="w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Auth section -->
|
||||
<div class="mt-12 pt-8 border-t border-ghost-800/50">
|
||||
<div
|
||||
:class="
|
||||
isMobile
|
||||
? 'mt-8 pt-6 border-t border-ghost-800/50'
|
||||
: 'mt-12 pt-8 border-t border-ghost-800/50'
|
||||
"
|
||||
>
|
||||
<div v-if="isAuthenticated" class="space-y-4">
|
||||
<NuxtLink
|
||||
to="/member/dashboard"
|
||||
class="block text-stone-300 hover:text-stone-100 hover:ethereal-text transition-all duration-300 py-2"
|
||||
class="block text-ghost-300 hover:text-ghost-100 hover:ethereal-text transition-all duration-300 py-2"
|
||||
@click="handleNavigate"
|
||||
>
|
||||
<span class="block text-sm text-whisper-400 mb-1">{{
|
||||
memberData?.name || "Member"
|
||||
|
|
@ -42,14 +65,14 @@
|
|||
Dashboard
|
||||
</NuxtLink>
|
||||
<button
|
||||
@click="logout"
|
||||
class="text-stone-500 hover:text-stone-300 transition-all duration-300 text-sm"
|
||||
@click="handleLogout"
|
||||
class="text-ghost-500 hover:text-ghost-300 transition-all duration-300 text-sm"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="space-y-4">
|
||||
<p class="text-stone-400 text-sm mb-4">
|
||||
<p class="text-ghost-400 text-sm mb-4">
|
||||
Enter your email to receive a login link
|
||||
</p>
|
||||
|
||||
|
|
@ -97,8 +120,30 @@
|
|||
<script setup>
|
||||
import { reactive, ref, computed } from "vue";
|
||||
|
||||
const props = defineProps({
|
||||
isMobile: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["navigate"]);
|
||||
|
||||
const { isAuthenticated, logout, memberData } = useAuth();
|
||||
|
||||
const handleNavigate = () => {
|
||||
if (props.isMobile) {
|
||||
emit("navigate");
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
await logout();
|
||||
if (props.isMobile) {
|
||||
emit("navigate");
|
||||
}
|
||||
};
|
||||
|
||||
const publicNavigationItems = [
|
||||
{ label: "Home", path: "/", accent: "entry point" },
|
||||
{ label: "About", path: "/about", accent: "who we are" },
|
||||
|
|
@ -112,7 +157,6 @@ const memberNavigationItems = [
|
|||
{ label: "Events", path: "/events" },
|
||||
{ label: "Members", path: "/members" },
|
||||
{ label: "Resources", path: "/resources" },
|
||||
{ label: "Updates", path: "/updates" },
|
||||
{ label: "Peer Support", path: "/peer-support" },
|
||||
{ label: "Profile", path: "/member/profile" },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<img
|
||||
:src="transformedImageUrl"
|
||||
:alt="modelValue.alt || 'Event image'"
|
||||
class="w-full h-48 object-cover rounded-lg border border-gray-300"
|
||||
class="w-full h-48 object-cover rounded-lg border border-neutral-200"
|
||||
@error="console.log('Image failed to load:', transformedImageUrl)"
|
||||
@load="console.log('Image loaded successfully:', transformedImageUrl)"
|
||||
/>
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
:value="modelValue.alt || ''"
|
||||
@input="updateAltText($event.target.value)"
|
||||
placeholder="Describe this image..."
|
||||
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
class="w-full border border-neutral-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -91,111 +91,113 @@
|
|||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
})
|
||||
default: () => null,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const isDragging = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const uploadProgress = ref(0)
|
||||
const errorMessage = ref('')
|
||||
const fileInput = ref()
|
||||
const isDragging = ref(false);
|
||||
const isUploading = ref(false);
|
||||
const uploadProgress = ref(0);
|
||||
const errorMessage = ref("");
|
||||
const fileInput = ref();
|
||||
|
||||
// Transform image URL for preview (smaller size)
|
||||
const transformedImageUrl = computed(() => {
|
||||
console.log('modelValue in computed:', props.modelValue)
|
||||
console.log("modelValue in computed:", props.modelValue);
|
||||
|
||||
// If we have the direct URL, use it
|
||||
if (props.modelValue?.url) {
|
||||
console.log('Using direct URL:', props.modelValue.url)
|
||||
return props.modelValue.url
|
||||
console.log("Using direct URL:", props.modelValue.url);
|
||||
return props.modelValue.url;
|
||||
}
|
||||
|
||||
// Otherwise try to construct from publicId
|
||||
if (props.modelValue?.publicId) {
|
||||
const config = useRuntimeConfig()
|
||||
const constructedUrl = `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/w_400,h_200,c_fill,f_auto,q_auto/${props.modelValue.publicId}`
|
||||
console.log('Constructed URL:', constructedUrl)
|
||||
return constructedUrl
|
||||
const config = useRuntimeConfig();
|
||||
const constructedUrl = `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/w_400,h_200,c_fill,f_auto,q_auto/${props.modelValue.publicId}`;
|
||||
console.log("Constructed URL:", constructedUrl);
|
||||
return constructedUrl;
|
||||
}
|
||||
|
||||
console.log('No URL or publicId found')
|
||||
return ''
|
||||
})
|
||||
console.log("No URL or publicId found");
|
||||
return "";
|
||||
});
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0]
|
||||
const file = event.target.files[0];
|
||||
if (file) {
|
||||
uploadFile(file)
|
||||
uploadFile(file);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event) => {
|
||||
isDragging.value = false
|
||||
const files = event.dataTransfer.files
|
||||
isDragging.value = false;
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
uploadFile(files[0])
|
||||
uploadFile(files[0]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uploadFile = async (file) => {
|
||||
// Validate file
|
||||
if (!file.type.startsWith('image/')) {
|
||||
errorMessage.value = 'Please select an image file'
|
||||
return
|
||||
if (!file.type.startsWith("image/")) {
|
||||
errorMessage.value = "Please select an image file";
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) { // 10MB
|
||||
errorMessage.value = 'File size must be less than 10MB'
|
||||
return
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
// 10MB
|
||||
errorMessage.value = "File size must be less than 10MB";
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
isUploading.value = true
|
||||
uploadProgress.value = 0
|
||||
errorMessage.value = "";
|
||||
isUploading.value = true;
|
||||
uploadProgress.value = 0;
|
||||
|
||||
try {
|
||||
// Create form data for upload
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
// Upload to Cloudinary
|
||||
const response = await $fetch(`/api/upload/image`, {
|
||||
method: 'POST',
|
||||
method: "POST",
|
||||
body: formData,
|
||||
onUploadProgress: (progress) => {
|
||||
uploadProgress.value = Math.round((progress.loaded / progress.total) * 100)
|
||||
}
|
||||
})
|
||||
uploadProgress.value = Math.round(
|
||||
(progress.loaded / progress.total) * 100,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
console.log('Upload response:', response)
|
||||
console.log("Upload response:", response);
|
||||
|
||||
// Update the model value
|
||||
emit('update:modelValue', {
|
||||
emit("update:modelValue", {
|
||||
url: response.secure_url,
|
||||
publicId: response.public_id,
|
||||
alt: ''
|
||||
})
|
||||
|
||||
alt: "",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
errorMessage.value = 'Upload failed. Please try again.'
|
||||
console.error("Upload failed:", error);
|
||||
errorMessage.value = "Upload failed. Please try again.";
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
uploadProgress.value = 0
|
||||
isUploading.value = false;
|
||||
uploadProgress.value = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = () => {
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
emit("update:modelValue", null);
|
||||
};
|
||||
|
||||
const updateAltText = (altText) => {
|
||||
emit('update:modelValue', {
|
||||
emit("update:modelValue", {
|
||||
...props.modelValue,
|
||||
alt: altText
|
||||
})
|
||||
}
|
||||
alt: altText,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
|
@ -28,21 +28,21 @@
|
|||
'rounded-2xl p-6 md:p-8 mb-12 backdrop-blur-sm',
|
||||
props.theme === 'ethereal'
|
||||
? 'bg-ghost-800/60 border border-ghost-700 ethereal-glow halftone-texture'
|
||||
: 'bg-white dark:bg-gray-800 shadow-xl border border-blue-200 dark:border-blue-800',
|
||||
: 'bg-[--ui-bg-elevated] shadow-xl border border-blue-200',
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center justify-between gap-3 md:gap-4">
|
||||
<button
|
||||
:class="[
|
||||
'p-3 rounded-full transition-all duration-300',
|
||||
'p-2 md:p-3 rounded-full transition-all duration-300 flex-shrink-0',
|
||||
props.theme === 'ethereal'
|
||||
? 'bg-whisper-600/80 text-stone-100 hover:bg-whisper-500 ethereal-glow'
|
||||
? 'bg-whisper-600/80 text-ghost-100 hover:bg-whisper-500 ethereal-glow'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||
]"
|
||||
@click="$emit('prev')"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
class="w-5 h-5 md:w-6 md:h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
@ -56,14 +56,14 @@
|
|||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="text-center flex-1">
|
||||
<div class="text-center flex-1 min-w-0">
|
||||
<slot name="interactive-content">
|
||||
<p
|
||||
:class="[
|
||||
'text-lg',
|
||||
'text-base md:text-lg',
|
||||
props.theme === 'ethereal'
|
||||
? 'text-stone-200'
|
||||
: 'text-gray-600 dark:text-gray-300',
|
||||
? 'text-ghost-200'
|
||||
: 'text-[--ui-text-muted]',
|
||||
]"
|
||||
>
|
||||
{{
|
||||
|
|
@ -76,15 +76,15 @@
|
|||
|
||||
<button
|
||||
:class="[
|
||||
'p-3 rounded-full transition-all duration-300',
|
||||
'p-2 md:p-3 rounded-full transition-all duration-300 flex-shrink-0',
|
||||
props.theme === 'ethereal'
|
||||
? 'bg-whisper-600/80 text-stone-100 hover:bg-whisper-500 ethereal-glow'
|
||||
? 'bg-whisper-600/80 text-ghost-100 hover:bg-whisper-500 ethereal-glow'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||
]"
|
||||
@click="$emit('next')"
|
||||
>
|
||||
<svg
|
||||
class="w-6 h-6"
|
||||
class="w-5 h-5 md:w-6 md:h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
|
|
@ -178,12 +178,10 @@ defineEmits(["prev", "next"]);
|
|||
|
||||
const backgroundClass = computed(() => {
|
||||
const themes = {
|
||||
blue: "bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800",
|
||||
purple:
|
||||
"bg-gradient-to-br from-purple-50 to-violet-100 dark:from-gray-900 dark:to-purple-900/20",
|
||||
emerald:
|
||||
"bg-gradient-to-br from-emerald-50 to-teal-100 dark:from-gray-900 dark:to-emerald-900/20",
|
||||
gray: "bg-gray-50 dark:bg-gray-900",
|
||||
blue: "bg-gradient-to-br from-blue-50 to-indigo-100",
|
||||
purple: "bg-gradient-to-br from-purple-50 to-violet-100",
|
||||
emerald: "bg-gradient-to-br from-emerald-50 to-teal-100",
|
||||
gray: "bg-neutral-100",
|
||||
ethereal:
|
||||
"bg-gradient-to-br from-ghost-900 via-ghost-800 to-whisper-900 halftone-texture",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,25 +1,22 @@
|
|||
<template>
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-400">{{ label }}:</span>
|
||||
<UButtonGroup size="xs">
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<span class="text-ghost-300 font-medium">{{ label }}:</span>
|
||||
<UButtonGroup size="sm" class="privacy-toggle-group">
|
||||
<UButton
|
||||
:variant="modelValue === 'public' ? 'solid' : 'ghost'"
|
||||
:color="modelValue === 'public' ? 'blue' : 'gray'"
|
||||
@click="updateValue('public')"
|
||||
>
|
||||
Public
|
||||
</UButton>
|
||||
<UButton
|
||||
:variant="modelValue === 'members' ? 'solid' : 'ghost'"
|
||||
:color="modelValue === 'members' ? 'blue' : 'gray'"
|
||||
:variant="modelValue === 'members' ? 'solid' : 'outline'"
|
||||
:color="modelValue === 'members' ? 'blue' : 'neutral'"
|
||||
@click="updateValue('members')"
|
||||
class="privacy-toggle-btn"
|
||||
:class="{ 'is-selected': modelValue === 'members' }"
|
||||
>
|
||||
Members
|
||||
</UButton>
|
||||
<UButton
|
||||
:variant="modelValue === 'private' ? 'solid' : 'ghost'"
|
||||
:color="modelValue === 'private' ? 'blue' : 'gray'"
|
||||
:variant="modelValue === 'private' ? 'solid' : 'outline'"
|
||||
:color="modelValue === 'private' ? 'blue' : 'neutral'"
|
||||
@click="updateValue('private')"
|
||||
class="privacy-toggle-btn"
|
||||
:class="{ 'is-selected': modelValue === 'private' }"
|
||||
>
|
||||
Private
|
||||
</UButton>
|
||||
|
|
@ -31,17 +28,43 @@
|
|||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: 'members'
|
||||
default: "members",
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Privacy'
|
||||
}
|
||||
})
|
||||
default: "Privacy",
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
const emit = defineEmits(["update:modelValue"]);
|
||||
|
||||
const updateValue = (value) => {
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
emit("update:modelValue", value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Unselected buttons - lighter background for visibility */
|
||||
:deep(.privacy-toggle-btn:not(.is-selected)) {
|
||||
background-color: rgb(68 64 60) !important; /* ghost-700 equivalent */
|
||||
border-color: rgb(87 83 78) !important; /* ghost-600 equivalent */
|
||||
color: rgb(214 211 209) !important; /* ghost-300 equivalent */
|
||||
}
|
||||
|
||||
:deep(.privacy-toggle-btn:not(.is-selected):hover) {
|
||||
background-color: rgb(87 83 78) !important; /* ghost-600 equivalent */
|
||||
border-color: rgb(120 113 108) !important; /* ghost-500 equivalent */
|
||||
}
|
||||
|
||||
/* Selected buttons - bright blue to stand out */
|
||||
:deep(.privacy-toggle-btn.is-selected) {
|
||||
background-color: rgb(59 130 246) !important; /* blue-500 */
|
||||
border-color: rgb(59 130 246) !important; /* blue-500 */
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
:deep(.privacy-toggle-btn.is-selected:hover) {
|
||||
background-color: rgb(37 99 235) !important; /* blue-600 */
|
||||
border-color: rgb(37 99 235) !important; /* blue-600 */
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
/>
|
||||
<div
|
||||
v-else
|
||||
class="w-12 h-12 rounded-full bg-stone-700 flex items-center justify-center text-stone-300 font-bold"
|
||||
class="w-12 h-12 rounded-full bg-ghost-700 flex items-center justify-center text-ghost-300 font-bold"
|
||||
>
|
||||
{{ update.author?.name?.charAt(0)?.toUpperCase() || "?" }}
|
||||
</div>
|
||||
|
|
@ -23,30 +23,30 @@
|
|||
<!-- Header -->
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<div>
|
||||
<h3 class="font-semibold text-stone-100">
|
||||
<h3 class="font-semibold text-ghost-100">
|
||||
<NuxtLink
|
||||
v-if="update.author?._id"
|
||||
:to="`/updates/user/${update.author._id}`"
|
||||
class="hover:text-stone-300 transition-colors"
|
||||
class="hover:text-ghost-300 transition-colors"
|
||||
>
|
||||
{{ update.author.name }}
|
||||
</NuxtLink>
|
||||
<span v-else>Unknown Member</span>
|
||||
</h3>
|
||||
<div class="flex items-center gap-2 text-sm text-stone-400">
|
||||
<div class="flex items-center gap-2 text-sm text-ghost-400">
|
||||
<time :datetime="update.createdAt">
|
||||
{{ formatDate(update.createdAt) }}
|
||||
</time>
|
||||
<span v-if="isEdited" class="text-stone-500">(edited)</span>
|
||||
<span v-if="isEdited" class="text-ghost-500">(edited)</span>
|
||||
<span
|
||||
v-if="update.privacy === 'private'"
|
||||
class="px-2 py-0.5 bg-stone-700 text-stone-300 rounded text-xs"
|
||||
class="px-2 py-0.5 bg-ghost-700 text-ghost-300 rounded text-xs"
|
||||
>
|
||||
Private
|
||||
</span>
|
||||
<span
|
||||
v-if="update.privacy === 'public'"
|
||||
class="px-2 py-0.5 bg-stone-700 text-stone-300 rounded text-xs"
|
||||
class="px-2 py-0.5 bg-ghost-700 text-ghost-300 rounded text-xs"
|
||||
>
|
||||
Public
|
||||
</span>
|
||||
|
|
@ -73,12 +73,12 @@
|
|||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="text-stone-200 whitespace-pre-wrap break-words mb-3">
|
||||
<div class="text-ghost-200 whitespace-pre-wrap break-words mb-3">
|
||||
<template v-if="showPreview && update.content.length > 300">
|
||||
{{ update.content.substring(0, 300) }}...
|
||||
<NuxtLink
|
||||
:to="`/updates/${update._id}`"
|
||||
class="text-stone-400 hover:text-stone-300 ml-1"
|
||||
class="text-ghost-400 hover:text-ghost-300 ml-1"
|
||||
>
|
||||
Read more
|
||||
</NuxtLink>
|
||||
|
|
@ -100,14 +100,14 @@
|
|||
</div>
|
||||
|
||||
<!-- Footer actions -->
|
||||
<div class="flex items-center gap-4 text-sm text-stone-400">
|
||||
<div class="flex items-center gap-4 text-sm text-ghost-400">
|
||||
<NuxtLink
|
||||
:to="`/updates/${update._id}`"
|
||||
class="hover:text-stone-300 transition-colors"
|
||||
class="hover:text-ghost-300 transition-colors"
|
||||
>
|
||||
View full update
|
||||
</NuxtLink>
|
||||
<span v-if="update.commentsEnabled" class="text-stone-500">
|
||||
<span v-if="update.commentsEnabled" class="text-ghost-500">
|
||||
Comments (coming soon)
|
||||
</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,19 +11,19 @@
|
|||
</UFormField>
|
||||
|
||||
<!-- Privacy Settings -->
|
||||
<div class="border border-stone-700 rounded-lg p-4 bg-stone-800/30">
|
||||
<h3 class="text-sm font-medium text-stone-200 mb-4">Privacy Settings</h3>
|
||||
<div class="border border-ghost-700 rounded-lg p-4 bg-ghost-800/30">
|
||||
<h3 class="text-sm font-medium text-ghost-200 mb-4">Privacy Settings</h3>
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="formData.privacy"
|
||||
type="radio"
|
||||
value="public"
|
||||
class="w-4 h-4 text-stone-400"
|
||||
class="w-4 h-4 text-ghost-400"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-stone-200 font-medium">Public</div>
|
||||
<div class="text-sm text-stone-400">
|
||||
<div class="text-ghost-200 font-medium">Public</div>
|
||||
<div class="text-sm text-ghost-400">
|
||||
Visible to everyone, including non-members
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -34,11 +34,11 @@
|
|||
v-model="formData.privacy"
|
||||
type="radio"
|
||||
value="members"
|
||||
class="w-4 h-4 text-stone-400"
|
||||
class="w-4 h-4 text-ghost-400"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-stone-200 font-medium">Members Only</div>
|
||||
<div class="text-sm text-stone-400">
|
||||
<div class="text-ghost-200 font-medium">Members Only</div>
|
||||
<div class="text-sm text-ghost-400">
|
||||
Only visible to Ghost Guild members
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -49,11 +49,11 @@
|
|||
v-model="formData.privacy"
|
||||
type="radio"
|
||||
value="private"
|
||||
class="w-4 h-4 text-stone-400"
|
||||
class="w-4 h-4 text-ghost-400"
|
||||
/>
|
||||
<div>
|
||||
<div class="text-stone-200 font-medium">Private</div>
|
||||
<div class="text-sm text-stone-400">Only visible to you</div>
|
||||
<div class="text-ghost-200 font-medium">Private</div>
|
||||
<div class="text-sm text-ghost-400">Only visible to you</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
|
@ -66,15 +66,17 @@
|
|||
<div class="flex items-center gap-3">
|
||||
<USwitch v-model="formData.commentsEnabled" />
|
||||
<div>
|
||||
<div class="text-stone-200 font-medium">Enable Comments</div>
|
||||
<div class="text-sm text-stone-400">
|
||||
<div class="text-ghost-200 font-medium">Enable Comments</div>
|
||||
<div class="text-sm text-ghost-400">
|
||||
Allow members to comment on this update
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-between items-center pt-4 border-t border-stone-700">
|
||||
<div
|
||||
class="flex justify-between items-center pt-4 border-t border-ghost-700"
|
||||
>
|
||||
<UButton variant="ghost" color="neutral" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</UButton>
|
||||
|
|
|
|||
|
|
@ -1,46 +1,54 @@
|
|||
export const useAuth = () => {
|
||||
const memberData = useState('auth.member', () => null)
|
||||
const memberData = useState("auth.member", () => null);
|
||||
|
||||
const isAuthenticated = computed(() => !!memberData.value)
|
||||
const isAuthenticated = computed(() => !!memberData.value);
|
||||
|
||||
const isMember = computed(() => !!memberData.value)
|
||||
const isMember = computed(() => !!memberData.value);
|
||||
|
||||
const checkMemberStatus = async () => {
|
||||
console.log('🔍 checkMemberStatus called')
|
||||
console.log(' - Current memberData:', !!memberData.value)
|
||||
console.log("🔍 checkMemberStatus called");
|
||||
console.log(" - Current memberData:", !!memberData.value);
|
||||
|
||||
try {
|
||||
console.log(' - Making API call to /api/auth/member...')
|
||||
const response = await $fetch('/api/auth/member')
|
||||
console.log(' - API response received:', { email: response.email, id: response.id })
|
||||
memberData.value = response
|
||||
console.log(' - ✅ Member authenticated successfully')
|
||||
return true
|
||||
console.log(" - Making API call to /api/auth/member...");
|
||||
const response = await $fetch("/api/auth/member");
|
||||
console.log(" - API response received:", {
|
||||
email: response.email,
|
||||
id: response.id,
|
||||
});
|
||||
memberData.value = response;
|
||||
console.log(" - ✅ Member authenticated successfully");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(' - ❌ Failed to fetch member status:', error.statusCode, error.statusMessage)
|
||||
memberData.value = null
|
||||
console.log(' - Cleared memberData')
|
||||
return false
|
||||
}
|
||||
console.error(
|
||||
" - ❌ Failed to fetch member status:",
|
||||
error.statusCode,
|
||||
error.statusMessage,
|
||||
);
|
||||
memberData.value = null;
|
||||
console.log(" - Cleared memberData");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await $fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
memberData.value = null
|
||||
await navigateTo('/')
|
||||
await $fetch("/api/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
memberData.value = null;
|
||||
await navigateTo("/");
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error)
|
||||
}
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isAuthenticated: readonly(isAuthenticated),
|
||||
isMember: readonly(isMember),
|
||||
memberData: readonly(memberData),
|
||||
checkMemberStatus,
|
||||
logout
|
||||
}
|
||||
}
|
||||
fetchMember: checkMemberStatus, // Alias for consistency
|
||||
logout,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
16
app/composables/usePeerSupport.js
Normal file
16
app/composables/usePeerSupport.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export const usePeerSupport = () => {
|
||||
const updateSettings = async (settings) => {
|
||||
return await $fetch('/api/members/me/peer-support', {
|
||||
method: 'PATCH',
|
||||
body: settings
|
||||
});
|
||||
};
|
||||
|
||||
const getSupporters = async (topic) => {
|
||||
return await $fetch('/api/peer-support', {
|
||||
query: topic ? { topic } : {}
|
||||
});
|
||||
};
|
||||
|
||||
return { updateSettings, getSupporters };
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div class="min-h-screen bg-stone-800 flex relative">
|
||||
<div class="min-h-screen bg-ghost-900 flex relative">
|
||||
<!-- Background image at top - full page width -->
|
||||
<div
|
||||
class="absolute inset-x-0 pointer-events-none z-0"
|
||||
|
|
@ -21,15 +21,48 @@
|
|||
"
|
||||
/>
|
||||
|
||||
<!-- Mobile Header -->
|
||||
<div
|
||||
class="lg:hidden fixed top-0 left-0 right-0 z-50 bg-ghost-900/95 backdrop-blur-md border-b border-ghost-700"
|
||||
>
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<NuxtLink
|
||||
to="/"
|
||||
class="text-lg font-bold text-white ethereal-text tracking-wider"
|
||||
>
|
||||
Ghost Guild
|
||||
</NuxtLink>
|
||||
<UButton
|
||||
icon="i-lucide-menu"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="lg"
|
||||
@click="isMobileMenuOpen = true"
|
||||
aria-label="Open menu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Column - Left -->
|
||||
<div class="flex-1 overflow-y-auto relative z-[5]">
|
||||
<div class="p-8 md:p-12 lg:p-16 max-w-4xl relative">
|
||||
<div class="p-4 pt-20 md:p-8 md:pt-8 lg:p-16 max-w-4xl relative">
|
||||
<slot />
|
||||
</div>
|
||||
<AppFooter />
|
||||
</div>
|
||||
|
||||
<!-- Navigation Column - Right -->
|
||||
<AppNavigation class="relative z-20" />
|
||||
<!-- Desktop Navigation Column - Right -->
|
||||
<AppNavigation class="hidden lg:block relative z-20" />
|
||||
|
||||
<!-- Mobile Navigation Drawer -->
|
||||
<USlideover v-model:open="isMobileMenuOpen" side="right">
|
||||
<template #body>
|
||||
<AppNavigation :is-mobile="true" @navigate="isMobileMenuOpen = false" />
|
||||
</template>
|
||||
</USlideover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const isMobileMenuOpen = ref(false);
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -9,22 +9,22 @@
|
|||
/>
|
||||
|
||||
<!-- How Ghost Guild Works -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<section class="py-20 bg-[--ui-bg]">
|
||||
<UContainer>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-6">
|
||||
<h2 class="text-3xl font-bold text-[--ui-text] mb-6">
|
||||
How Ghost Guild Works
|
||||
</h2>
|
||||
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
<p class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
<p class="text-xl font-semibold text-[--ui-text] mb-6">
|
||||
Everyone gets everything. Your circle reflects where you are in
|
||||
your cooperative journey. Your financial contribution reflects
|
||||
what you can afford. These are completely separate choices.
|
||||
</p>
|
||||
|
||||
<ul
|
||||
class="text-lg leading-relaxed text-gray-700 dark:text-gray-300 space-y-3 mb-12"
|
||||
class="text-lg leading-relaxed text-[--ui-text-muted] space-y-3 mb-12"
|
||||
>
|
||||
<li>
|
||||
<strong>Equal access:</strong> The entire knowledge commons, all
|
||||
|
|
@ -49,13 +49,13 @@
|
|||
</section>
|
||||
|
||||
<!-- Find Your Circle -->
|
||||
<section class="py-20 bg-gray-50 dark:bg-gray-800">
|
||||
<section class="py-20 bg-[--ui-bg-elevated]">
|
||||
<UContainer>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
|
||||
Find Your Circle
|
||||
</h2>
|
||||
<p class="text-lg text-gray-700 dark:text-gray-300 mb-12">
|
||||
<p class="text-lg text-[--ui-text-muted] mb-12">
|
||||
Circles help us provide relevant guidance and connect you with
|
||||
others at similar stages. Choose based on where you are, not what
|
||||
you want to access.
|
||||
|
|
@ -63,21 +63,19 @@
|
|||
|
||||
<div class="space-y-12">
|
||||
<!-- Community Circle -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-8">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
<div class="bg-[--ui-bg] rounded-xl p-8">
|
||||
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
|
||||
Community Circle
|
||||
</h3>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
|
||||
<p class="text-lg text-[--ui-text-muted] mb-6">
|
||||
You're exploring what cooperatives could mean for your work
|
||||
</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
|
||||
>
|
||||
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
|
||||
Where you might be:
|
||||
</h4>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<ul class="text-[--ui-text-muted] space-y-2">
|
||||
<li>
|
||||
Curious about alternatives to traditional studio structures
|
||||
</li>
|
||||
|
|
@ -88,12 +86,10 @@
|
|||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h4
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
|
||||
>
|
||||
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
|
||||
We'll help you navigate:
|
||||
</h4>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<ul class="text-[--ui-text-muted] space-y-2">
|
||||
<li>Understanding cooperative basics</li>
|
||||
<li>Connecting with others asking similar questions</li>
|
||||
<li>Exploring real examples from game studios</li>
|
||||
|
|
@ -102,12 +98,10 @@
|
|||
</div>
|
||||
|
||||
<div>
|
||||
<h4
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
|
||||
>
|
||||
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
|
||||
You might be:
|
||||
</h4>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<ul class="text-[--ui-text-muted] space-y-2">
|
||||
<li>Individual game workers</li>
|
||||
<li>Researchers and students</li>
|
||||
<li>Industry allies and supporters</li>
|
||||
|
|
@ -117,11 +111,11 @@
|
|||
</div>
|
||||
|
||||
<!-- Founder Circle -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-8">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
<div class="bg-[--ui-bg] rounded-xl p-8">
|
||||
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
|
||||
Founder Circle
|
||||
</h3>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
|
||||
<p class="text-lg text-[--ui-text-muted] mb-6">
|
||||
You're actively building or transitioning to a cooperative model
|
||||
</p>
|
||||
|
||||
|
|
@ -188,11 +182,11 @@
|
|||
</div>
|
||||
|
||||
<!-- Practitioner Circle -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-8">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
<div class="bg-[--ui-bg] rounded-xl p-8">
|
||||
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
|
||||
Practitioner Circle
|
||||
</h3>
|
||||
<p class="text-lg text-gray-600 dark:text-gray-400 mb-6">
|
||||
<p class="text-lg text-[--ui-text-muted] mb-6">
|
||||
You're operating a cooperative and contributing to the field
|
||||
</p>
|
||||
|
||||
|
|
@ -246,14 +240,14 @@
|
|||
</section>
|
||||
|
||||
<!-- Important Notes -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<section class="py-20 bg-[--ui-bg]">
|
||||
<UContainer>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-8">
|
||||
<h2 class="text-3xl font-bold text-[--ui-text] mb-8">
|
||||
Important Notes
|
||||
</h2>
|
||||
|
||||
<div class="space-y-6 text-lg text-gray-700 dark:text-gray-300">
|
||||
<div class="space-y-6 text-lg text-[--ui-text-muted]">
|
||||
<p>
|
||||
<strong>Movement between circles is fluid.</strong> As you move
|
||||
along in your journey, you can shift circles anytime. Just let us
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@
|
|||
<section class="mb-24">
|
||||
<div class="relative">
|
||||
<h1
|
||||
class="text-6xl md:text-8xl font-bold text-stone-100 ethereal-text leading-tight mb-8"
|
||||
class="text-6xl md:text-8xl font-bold text-ghost-100 ethereal-text leading-tight mb-8"
|
||||
>
|
||||
Get in Touch
|
||||
</h1>
|
||||
|
||||
<div class="mb-16">
|
||||
<p class="text-stone-100 text-lg max-w-md">
|
||||
<p class="text-ghost-100 text-lg max-w-md">
|
||||
We'd be happy to answer any questions<br />
|
||||
you might have about Ghost Guild
|
||||
</p>
|
||||
|
|
@ -21,7 +21,7 @@
|
|||
<!-- Contact Form -->
|
||||
<section class="mb-32 relative">
|
||||
<div class="mb-12">
|
||||
<h2 class="text-3xl font-light text-stone-200 mb-4">
|
||||
<h2 class="text-3xl font-light text-ghost-200 mb-4">
|
||||
Send us a message (or email hello@ghostguild.org)
|
||||
</h2>
|
||||
</div>
|
||||
|
|
@ -79,7 +79,7 @@
|
|||
:loading="isSubmitting"
|
||||
:disabled="!isFormValid"
|
||||
size="xl"
|
||||
class="px-12 border border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 hover:ethereal-text transition-all duration-500"
|
||||
class="px-12 border border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 hover:ethereal-text transition-all duration-500"
|
||||
>
|
||||
Send Message
|
||||
</UButton>
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
</div>
|
||||
|
||||
<div v-if="error" class="mt-6 p-4 border border-ghost-700 bg-ghost-900">
|
||||
<p class="text-stone-300 text-center">
|
||||
<p class="text-ghost-300 text-center">
|
||||
{{ error }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="pending"
|
||||
class="min-h-screen bg-stone-900 flex items-center justify-center"
|
||||
class="min-h-screen bg-ghost-900 flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-stone-200">Loading event details...</p>
|
||||
<p class="text-ghost-200">Loading event details...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="min-h-screen bg-stone-900 flex items-center justify-center"
|
||||
class="min-h-screen bg-ghost-900 flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<Icon
|
||||
name="heroicons:exclamation-triangle"
|
||||
class="w-16 h-16 text-red-500 mx-auto mb-4"
|
||||
/>
|
||||
<h2 class="text-2xl font-bold text-stone-100 mb-2">Event Not Found</h2>
|
||||
<p class="text-stone-300 mb-6">
|
||||
<h2 class="text-2xl font-bold text-ghost-100 mb-2">Event Not Found</h2>
|
||||
<p class="text-ghost-300 mb-6">
|
||||
The event you're looking for doesn't exist.
|
||||
</p>
|
||||
<NuxtLink to="/events" class="text-blue-400 hover:underline">
|
||||
|
|
@ -64,11 +64,11 @@
|
|||
<PageHeader v-else :title="event.title" theme="blue" size="medium" />
|
||||
|
||||
<!-- Event Details Section -->
|
||||
<section class="py-16 bg-stone-900">
|
||||
<section class="py-16 bg-ghost-900">
|
||||
<UContainer>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Event Meta Info -->
|
||||
<div class="bg-stone-800 rounded-xl p-6 mb-8 border border-stone-700">
|
||||
<div class="bg-ghost-800 rounded-xl p-6 mb-8 border border-ghost-700">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div class="flex items-center space-x-3">
|
||||
<Icon
|
||||
|
|
@ -76,8 +76,8 @@
|
|||
class="w-6 h-6 text-blue-400"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm text-stone-400">Date</p>
|
||||
<p class="font-semibold text-stone-100">
|
||||
<p class="text-sm text-ghost-400">Date</p>
|
||||
<p class="font-semibold text-ghost-100">
|
||||
{{ formatDate(event.startDate) }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -86,8 +86,8 @@
|
|||
<div class="flex items-center space-x-3">
|
||||
<Icon name="heroicons:clock" class="w-6 h-6 text-blue-400" />
|
||||
<div>
|
||||
<p class="text-sm text-stone-400">Time</p>
|
||||
<p class="font-semibold text-stone-100">
|
||||
<p class="text-sm text-ghost-400">Time</p>
|
||||
<p class="font-semibold text-ghost-100">
|
||||
{{ formatTime(event.startDate, event.endDate) }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -96,8 +96,8 @@
|
|||
<div class="flex items-center space-x-3">
|
||||
<Icon name="heroicons:map-pin" class="w-6 h-6 text-blue-400" />
|
||||
<div>
|
||||
<p class="text-sm text-stone-400">Location</p>
|
||||
<p class="font-semibold text-stone-100">
|
||||
<p class="text-sm text-ghost-400">Location</p>
|
||||
<p class="font-semibold text-ghost-100">
|
||||
{{ event.location }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -153,7 +153,7 @@
|
|||
>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Icon name="heroicons:user-group" class="w-5 h-5 text-blue-400" />
|
||||
<span class="text-sm font-medium text-stone-200"
|
||||
<span class="text-sm font-medium text-ghost-200"
|
||||
>Recommended for:</span
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
|
|
@ -170,15 +170,15 @@
|
|||
|
||||
<!-- Event Description -->
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none mb-12">
|
||||
<h2 class="text-2xl font-bold text-stone-100 mb-4">
|
||||
<h2 class="text-2xl font-bold text-ghost-100 mb-4">
|
||||
About This Event
|
||||
</h2>
|
||||
<p class="text-stone-200">
|
||||
<p class="text-ghost-200">
|
||||
{{ event.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="event.agenda && event.agenda.length > 0" class="mt-8">
|
||||
<h3 class="text-xl font-semibold text-stone-100 mb-4">
|
||||
<h3 class="text-xl font-semibold text-ghost-100 mb-4">
|
||||
Event Agenda
|
||||
</h3>
|
||||
<ul class="space-y-3">
|
||||
|
|
@ -192,7 +192,7 @@
|
|||
>
|
||||
{{ index + 1 }}
|
||||
</span>
|
||||
<span class="text-stone-200">{{ item }}</span>
|
||||
<span class="text-ghost-200">{{ item }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
|
@ -201,7 +201,7 @@
|
|||
v-if="event.speakers && event.speakers.length > 0"
|
||||
class="mt-8"
|
||||
>
|
||||
<h3 class="text-xl font-semibold text-stone-100 mb-4">
|
||||
<h3 class="text-xl font-semibold text-ghost-100 mb-4">
|
||||
Speakers
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
|
@ -211,21 +211,21 @@
|
|||
class="flex items-start space-x-4"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 bg-stone-700 rounded-full flex items-center justify-center"
|
||||
class="w-16 h-16 bg-ghost-700 rounded-full flex items-center justify-center"
|
||||
>
|
||||
<Icon
|
||||
name="heroicons:user"
|
||||
class="w-8 h-8 text-stone-500"
|
||||
class="w-8 h-8 text-ghost-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-semibold text-stone-100">
|
||||
<p class="font-semibold text-ghost-100">
|
||||
{{ speaker.name }}
|
||||
</p>
|
||||
<p class="text-sm text-stone-300">
|
||||
<p class="text-sm text-ghost-300">
|
||||
{{ speaker.role }}
|
||||
</p>
|
||||
<p class="text-sm text-stone-400 mt-1">
|
||||
<p class="text-sm text-ghost-400 mt-1">
|
||||
{{ speaker.bio }}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -237,9 +237,9 @@
|
|||
<!-- Registration Section -->
|
||||
<div
|
||||
v-if="!event.isCancelled"
|
||||
class="bg-stone-800 rounded-xl p-8 border border-stone-700"
|
||||
class="bg-ghost-800 rounded-xl p-8 border border-ghost-700"
|
||||
>
|
||||
<h3 class="text-xl font-bold text-stone-100 mb-6">
|
||||
<h3 class="text-xl font-bold text-ghost-100 mb-6">
|
||||
Register for This Event
|
||||
</h3>
|
||||
|
||||
|
|
@ -322,7 +322,7 @@
|
|||
<div>
|
||||
<label
|
||||
for="name"
|
||||
class="block text-sm font-medium text-stone-200 mb-2"
|
||||
class="block text-sm font-medium text-ghost-200 mb-2"
|
||||
>
|
||||
Full Name
|
||||
</label>
|
||||
|
|
@ -338,7 +338,7 @@
|
|||
<div>
|
||||
<label
|
||||
for="email"
|
||||
class="block text-sm font-medium text-stone-200 mb-2"
|
||||
class="block text-sm font-medium text-ghost-200 mb-2"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
|
|
@ -354,7 +354,7 @@
|
|||
<div>
|
||||
<label
|
||||
for="membershipLevel"
|
||||
class="block text-sm font-medium text-stone-200 mb-2"
|
||||
class="block text-sm font-medium text-ghost-200 mb-2"
|
||||
>
|
||||
Membership Status
|
||||
</label>
|
||||
|
|
@ -388,16 +388,16 @@
|
|||
<!-- Event Capacity -->
|
||||
<div
|
||||
v-if="event.maxAttendees"
|
||||
class="mt-6 pt-6 border-t border-stone-700"
|
||||
class="mt-6 pt-6 border-t border-ghost-700"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm text-stone-300">Event Capacity</span>
|
||||
<span class="text-sm text-ghost-300">Event Capacity</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-sm font-semibold text-stone-100">
|
||||
<span class="text-sm font-semibold text-ghost-100">
|
||||
{{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
|
||||
</span>
|
||||
<div
|
||||
class="w-24 h-2 bg-stone-700 rounded-full overflow-hidden"
|
||||
class="w-24 h-2 bg-ghost-700 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-blue-500 rounded-full"
|
||||
|
|
@ -410,9 +410,9 @@
|
|||
</div>
|
||||
|
||||
<!-- Additional Information -->
|
||||
<div class="mt-8 p-6 bg-stone-800 rounded-xl border border-stone-700">
|
||||
<h4 class="font-semibold text-stone-100 mb-3">Questions?</h4>
|
||||
<p class="text-sm text-stone-200 mb-3">
|
||||
<div class="mt-8 p-6 bg-ghost-800 rounded-xl border border-ghost-700">
|
||||
<h4 class="font-semibold text-ghost-100 mb-3">Questions?</h4>
|
||||
<p class="text-sm text-ghost-200 mb-3">
|
||||
If you have any questions about this event please drop us a line.
|
||||
</p>
|
||||
<a
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
/>
|
||||
|
||||
<!-- Events Section with Tabs -->
|
||||
<section class="py-20 bg-stone-900 dark:bg-stone-950">
|
||||
<section class="py-20 bg-ghost-900 dark:bg-ghost-950">
|
||||
<UContainer>
|
||||
<UTabs
|
||||
v-model="activeTab"
|
||||
|
|
@ -27,10 +27,10 @@
|
|||
class="group flex items-start gap-4 py-2 hover:opacity-80 transition-opacity"
|
||||
>
|
||||
<div class="flex-shrink-0 text-center">
|
||||
<div class="text-2xl font-bold text-stone-100">
|
||||
<div class="text-2xl font-bold text-ghost-100">
|
||||
{{ event.start.getDate() }}
|
||||
</div>
|
||||
<div class="text-xs text-stone-400 uppercase">
|
||||
<div class="text-xs text-ghost-400 uppercase">
|
||||
{{
|
||||
event.start.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start gap-2 mb-1">
|
||||
<h3
|
||||
class="text-lg font-semibold text-stone-100 group-hover:text-blue-400 transition-colors"
|
||||
class="text-lg font-semibold text-ghost-100 group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ event.title }}
|
||||
</h3>
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-stone-300 mb-2 line-clamp-2">
|
||||
<p class="text-sm text-ghost-300 mb-2 line-clamp-2">
|
||||
{{ event.content }}
|
||||
</p>
|
||||
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
|
||||
<Icon
|
||||
name="heroicons:arrow-right"
|
||||
class="w-5 h-5 text-stone-400 group-hover:text-blue-400 group-hover:translate-x-1 transition-all flex-shrink-0 mt-1"
|
||||
class="w-5 h-5 text-ghost-400 group-hover:text-blue-400 group-hover:translate-x-1 transition-all flex-shrink-0 mt-1"
|
||||
/>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
|
@ -83,10 +83,10 @@
|
|||
<ClientOnly>
|
||||
<div
|
||||
v-if="pending"
|
||||
class="min-h-[400px] bg-stone-700 rounded-xl flex items-center justify-center"
|
||||
class="min-h-[400px] bg-ghost-700 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<p class="text-stone-200">Loading events...</p>
|
||||
<p class="text-ghost-200">Loading events...</p>
|
||||
</div>
|
||||
</div>
|
||||
<VueCal
|
||||
|
|
@ -110,13 +110,13 @@
|
|||
/>
|
||||
<template #fallback>
|
||||
<div
|
||||
class="min-h-[400px] bg-stone-700 rounded-xl flex items-center justify-center"
|
||||
class="min-h-[400px] bg-ghost-700 rounded-xl flex items-center justify-center"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-stone-200">Loading calendar...</p>
|
||||
<p class="text-ghost-200">Loading calendar...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -130,14 +130,14 @@
|
|||
<!-- Event Series -->
|
||||
<section
|
||||
v-if="activeSeries.length > 0"
|
||||
class="py-20 bg-stone-800 dark:bg-stone-900"
|
||||
class="py-20 bg-ghost-800 dark:bg-ghost-900"
|
||||
>
|
||||
<UContainer>
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-stone-100 mb-8">
|
||||
<h2 class="text-3xl font-bold text-ghost-100 mb-8">
|
||||
Active Event Series
|
||||
</h2>
|
||||
<p class="text-stone-300 max-w-2xl mx-auto">
|
||||
<p class="text-ghost-300 max-w-2xl mx-auto">
|
||||
Multi-part workshops and recurring events designed to deepen your
|
||||
knowledge and build community connections.
|
||||
</p>
|
||||
|
|
@ -149,7 +149,7 @@
|
|||
<div
|
||||
v-for="series in activeSeries.slice(0, 6)"
|
||||
:key="series.id"
|
||||
class="bg-stone-900 rounded-xl p-6 shadow-lg border border-stone-700"
|
||||
class="bg-ghost-900 rounded-xl p-6 shadow-lg border border-ghost-700"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div
|
||||
|
|
@ -160,17 +160,17 @@
|
|||
>
|
||||
{{ formatSeriesType(series.type) }}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 text-xs text-stone-400">
|
||||
<div class="flex items-center gap-1 text-xs text-ghost-400">
|
||||
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
|
||||
<span>{{ series.eventCount }} events</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-stone-100 mb-2">
|
||||
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
|
||||
{{ series.title }}
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-stone-300 mb-4 line-clamp-2">
|
||||
<p class="text-sm text-ghost-300 mb-4 line-clamp-2">
|
||||
{{ series.description }}
|
||||
</p>
|
||||
|
||||
|
|
@ -186,22 +186,22 @@
|
|||
>
|
||||
{{ event.series?.position || "?" }}
|
||||
</div>
|
||||
<span class="text-stone-300 truncate">{{ event.title }}</span>
|
||||
<span class="text-ghost-300 truncate">{{ event.title }}</span>
|
||||
</div>
|
||||
<span class="text-stone-400">
|
||||
<span class="text-ghost-400">
|
||||
{{ formatEventDate(event.startDate) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="series.events.length > 3"
|
||||
class="text-xs text-stone-400 text-center pt-1"
|
||||
class="text-xs text-ghost-400 text-center pt-1"
|
||||
>
|
||||
+{{ series.events.length - 3 }} more events
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<div class="text-stone-400">
|
||||
<div class="text-ghost-400">
|
||||
{{ formatDateRange(series.startDate, series.endDate) }}
|
||||
</div>
|
||||
<span
|
||||
|
|
@ -223,20 +223,20 @@
|
|||
</section>
|
||||
|
||||
<!-- Attend Our Events -->
|
||||
<section class="py-20 bg-stone-800 dark:bg-stone-900">
|
||||
<section class="py-20 bg-ghost-800 dark:bg-ghost-900">
|
||||
<UContainer>
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-3xl font-bold text-stone-100 mb-8">
|
||||
<h2 class="text-3xl font-bold text-ghost-100 mb-8">
|
||||
Attend Our Events
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div
|
||||
class="bg-stone-900 rounded-2xl p-8 border border-stone-700 mb-12"
|
||||
class="bg-ghost-900 rounded-2xl p-8 border border-ghost-700 mb-12"
|
||||
>
|
||||
<div class="prose prose-lg dark:prose-invert max-w-none">
|
||||
<p class="text-lg leading-relaxed text-stone-200 mb-6">
|
||||
<p class="text-lg leading-relaxed text-ghost-200 mb-6">
|
||||
Our events are ,Lorem ipsum, dolor sit amet consectetur
|
||||
adipisicing elit. Quibusdam exercitationem delectus ab
|
||||
voluptates aspernatur, quia deleniti aut maxime, veniam
|
||||
|
|
@ -244,7 +244,7 @@
|
|||
dolorum alias nulla!
|
||||
</p>
|
||||
|
||||
<p class="text-lg leading-relaxed text-stone-200 mb-6">
|
||||
<p class="text-lg leading-relaxed text-ghost-200 mb-6">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
|
||||
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
|
||||
enim ad minim veniam, quis nostrud exercitation ullamco laboris
|
||||
|
|
@ -265,31 +265,31 @@
|
|||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-stone-100 mb-2">
|
||||
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
|
||||
Monthly Meetups
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-stone-300">
|
||||
<p class="text-sm text-ghost-300">
|
||||
Casual knowledge sharing sessions
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-stone-100 mb-2">
|
||||
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
|
||||
Workshops
|
||||
</h3>
|
||||
|
||||
<p class="text-sm text-stone-300">
|
||||
<p class="text-sm text-ghost-300">
|
||||
Hands-on learning about cooperative and worker-centric business
|
||||
models
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-stone-100 mb-2">
|
||||
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
|
||||
Social Events
|
||||
</h3>
|
||||
<p class="text-sm text-stone-300">
|
||||
<p class="text-sm text-ghost-300">
|
||||
Game nights, socials, and more
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@
|
|||
<div class="relative">
|
||||
<!-- Large artistic title -->
|
||||
<h1
|
||||
class="text-6xl md:text-8xl font-bold text-stone-100 ethereal-text leading-tight mb-8"
|
||||
class="text-6xl md:text-8xl font-bold text-ghost-100 ethereal-text leading-tight mb-8"
|
||||
>
|
||||
Become a Ghostie
|
||||
</h1>
|
||||
|
||||
<!-- Floating subtitle -->
|
||||
<div class="mb-16">
|
||||
<p class="text-stone-100 text-lg max-w-md">
|
||||
<p class="text-ghost-100 text-lg max-w-md">
|
||||
A community for creatives and game devs<br />
|
||||
exploring cooperative models
|
||||
</p>
|
||||
|
|
@ -33,7 +33,7 @@
|
|||
<div>
|
||||
<NuxtLink
|
||||
to="/join"
|
||||
class="inline-block px-8 py-3 border border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 hover:ethereal-text transition-all duration-500"
|
||||
class="inline-block px-8 py-3 border border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 hover:ethereal-text transition-all duration-500"
|
||||
>
|
||||
Join Us Today →
|
||||
</NuxtLink>
|
||||
|
|
@ -55,13 +55,13 @@
|
|||
>
|
||||
<!-- Content -->
|
||||
<div class="flex-1 max-w-lg">
|
||||
<h3 class="text-xl text-stone-100 mb-3">{{ circle.label }}</h3>
|
||||
<p class="text-stone-200 text-sm leading-relaxed mb-4">
|
||||
<h3 class="text-xl text-ghost-100 mb-3">{{ circle.label }}</h3>
|
||||
<p class="text-ghost-200 text-sm leading-relaxed mb-4">
|
||||
{{ circle.description }}
|
||||
</p>
|
||||
|
||||
<!-- Features as inline text -->
|
||||
<div class="text-sm text-stone-400">
|
||||
<div class="text-sm text-ghost-400">
|
||||
<span v-for="(feature, i) in circle.features" :key="feature">
|
||||
{{ feature
|
||||
}}<span v-if="i < circle.features.length - 1"> • </span>
|
||||
|
|
@ -77,7 +77,7 @@
|
|||
<!-- Why Join? - Diagonal Layout -->
|
||||
<section class="mb-32 relative">
|
||||
<div class="transform -rotate-1">
|
||||
<h2 class="text-3xl font-light text-stone-200 mb-12">Why Join?</h2>
|
||||
<h2 class="text-3xl font-light text-ghost-200 mb-12">Why Join?</h2>
|
||||
</div>
|
||||
|
||||
<div class="ml-12 relative">
|
||||
|
|
@ -86,11 +86,11 @@
|
|||
/>
|
||||
|
||||
<div class="max-w-2xl">
|
||||
<p class="text-stone-300 leading-loose text-lg mb-8">
|
||||
<p class="text-ghost-300 leading-loose text-lg mb-8">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
|
||||
</p>
|
||||
|
||||
<p class="text-stone-400 leading-relaxed ml-8">
|
||||
<p class="text-ghost-400 leading-relaxed ml-8">
|
||||
Sed do eiusmod tempor incididunt ut labore et dolore magna
|
||||
aliqua.<br />
|
||||
Ut enim ad minim veniam, quis nostrud exercitation.
|
||||
|
|
@ -98,7 +98,7 @@
|
|||
</div>
|
||||
|
||||
<div
|
||||
class="absolute -bottom-8 right-0 text-6xl text-stone-800 opacity-20 font-bold"
|
||||
class="absolute -bottom-8 right-0 text-6xl text-ghost-800 opacity-20 font-bold"
|
||||
>
|
||||
?
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -9,13 +9,13 @@
|
|||
/>
|
||||
|
||||
<!-- Membership Sign Up Form -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<section class="py-20 bg-[--ui-bg]">
|
||||
<UContainer class="max-w-4xl">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
|
||||
Membership Sign Up
|
||||
</h2>
|
||||
<p class="text-lg text-gray-700 dark:text-gray-300">
|
||||
<p class="text-lg text-[--ui-text]">
|
||||
Choose your circle to connect with others at your stage. Choose your
|
||||
contribution based on what you can afford. Everyone gets full
|
||||
access.
|
||||
|
|
@ -30,8 +30,8 @@
|
|||
:class="[
|
||||
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
|
||||
currentStep >= 1
|
||||
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
|
||||
? 'bg-neutral-900 text-neutral-50'
|
||||
: 'bg-neutral-200 text-neutral-500',
|
||||
]"
|
||||
>
|
||||
1
|
||||
|
|
@ -39,21 +39,16 @@
|
|||
<span
|
||||
class="ml-2 font-medium"
|
||||
:class="
|
||||
currentStep === 1
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-500'
|
||||
currentStep === 1 ? 'text-[--ui-text]' : 'text-neutral-500'
|
||||
"
|
||||
>
|
||||
Information
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="needsPayment" class="w-16 h-1 bg-neutral-200">
|
||||
<div
|
||||
v-if="needsPayment"
|
||||
class="w-16 h-1 bg-gray-200 dark:bg-gray-700"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-gray-900 dark:bg-white transition-all"
|
||||
class="h-full bg-neutral-900 transition-all"
|
||||
:style="{ width: currentStep >= 2 ? '100%' : '0%' }"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -63,8 +58,8 @@
|
|||
:class="[
|
||||
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
|
||||
currentStep >= 2
|
||||
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
|
||||
? 'bg-neutral-900 text-neutral-50'
|
||||
: 'bg-neutral-200 text-neutral-500',
|
||||
]"
|
||||
>
|
||||
2
|
||||
|
|
@ -72,18 +67,16 @@
|
|||
<span
|
||||
class="ml-2 font-medium"
|
||||
:class="
|
||||
currentStep === 2
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-500'
|
||||
currentStep === 2 ? 'text-[--ui-text]' : 'text-neutral-500'
|
||||
"
|
||||
>
|
||||
Payment
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="w-16 h-1 bg-gray-200 dark:bg-gray-700">
|
||||
<div class="w-16 h-1 bg-neutral-200">
|
||||
<div
|
||||
class="h-full bg-gray-900 dark:bg-white transition-all"
|
||||
class="h-full bg-neutral-900 transition-all"
|
||||
:style="{ width: currentStep >= 3 ? '100%' : '0%' }"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -93,8 +86,8 @@
|
|||
:class="[
|
||||
'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
|
||||
currentStep >= 3
|
||||
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-500',
|
||||
? 'bg-neutral-900 text-neutral-50'
|
||||
: 'bg-neutral-200 text-neutral-500',
|
||||
]"
|
||||
>
|
||||
<span v-if="needsPayment">3</span>
|
||||
|
|
@ -103,9 +96,7 @@
|
|||
<span
|
||||
class="ml-2 font-medium"
|
||||
:class="
|
||||
currentStep === 3
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-500'
|
||||
currentStep === 3 ? 'text-[--ui-text]' : 'text-neutral-500'
|
||||
"
|
||||
>
|
||||
Confirmation
|
||||
|
|
@ -120,7 +111,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Step 1: Information -->
|
||||
<div v-if="currentStep === 1" class="bg-white dark:bg-gray-800">
|
||||
<div v-if="currentStep === 1" class="bg-[--ui-bg-elevated]">
|
||||
<UForm :state="form" class="space-y-8" @submit="handleSubmit">
|
||||
<!-- Personal Information -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
|
@ -154,8 +145,8 @@
|
|||
class="flex flex-col p-6 rounded-lg border-2 cursor-pointer transition-all hover:shadow-md"
|
||||
:class="
|
||||
form.circle === option.value
|
||||
? 'border-gray-900 dark:border-white bg-gray-50 dark:bg-gray-800'
|
||||
: 'border-gray-200 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500'
|
||||
? 'border-neutral-900 bg-[--ui-bg]'
|
||||
: 'border-neutral-200 hover:border-neutral-400'
|
||||
"
|
||||
>
|
||||
<input
|
||||
|
|
@ -166,12 +157,12 @@
|
|||
class="mb-3"
|
||||
/>
|
||||
<div class="font-medium text-lg mb-2">{{ option.label }}</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="text-sm text-[--ui-text-muted]">
|
||||
{{ option.description }}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-3 italic">
|
||||
<p class="text-sm text-[--ui-text-muted] mt-3 italic">
|
||||
Not sure? Start with Community - you can always move.
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -213,23 +204,23 @@
|
|||
<!-- Step 2: Payment -->
|
||||
<div
|
||||
v-if="currentStep === 2"
|
||||
class="bg-white dark:bg-gray-800 rounded-xl p-8"
|
||||
class="bg-[--ui-bg-elevated] rounded-xl p-8"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
|
||||
Payment Information
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
<p class="text-[--ui-text-muted]">
|
||||
You're signing up for the {{ selectedTier.label }} plan
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
|
||||
<p class="text-lg font-semibold text-[--ui-text] mt-2">
|
||||
${{ selectedTier.amount }} CAD / month
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Payment Instructions -->
|
||||
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 mb-6">
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<div class="bg-[--ui-bg] rounded-lg p-6 mb-6">
|
||||
<p class="text-[--ui-text]">
|
||||
Click "Complete Payment" below to open the secure payment modal
|
||||
and verify your payment method.
|
||||
</p>
|
||||
|
|
@ -254,11 +245,11 @@
|
|||
<!-- Step 3: Confirmation -->
|
||||
<div
|
||||
v-if="currentStep === 3"
|
||||
class="bg-white dark:bg-gray-800 rounded-xl p-8"
|
||||
class="bg-[--ui-bg-elevated] rounded-xl p-8"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-20 h-20 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mx-auto mb-6"
|
||||
class="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6"
|
||||
>
|
||||
<svg
|
||||
class="w-10 h-10 text-green-500"
|
||||
|
|
@ -275,7 +266,7 @@
|
|||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
<h3 class="text-2xl font-bold text-[--ui-text] mb-4">
|
||||
Welcome to Ghost Guild!
|
||||
</h3>
|
||||
|
||||
|
|
@ -283,43 +274,39 @@
|
|||
<UAlert color="green" :title="successMessage" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 mb-6 text-left"
|
||||
>
|
||||
<div class="bg-[--ui-bg] rounded-lg p-6 mb-6 text-left">
|
||||
<h4 class="font-semibold mb-3">Membership Details:</h4>
|
||||
<dl class="space-y-2">
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-600 dark:text-gray-400">Name:</dt>
|
||||
<dt class="text-[--ui-text-muted]">Name:</dt>
|
||||
<dd class="font-medium">{{ form.name }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-600 dark:text-gray-400">Email:</dt>
|
||||
<dt class="text-[--ui-text-muted]">Email:</dt>
|
||||
<dd class="font-medium">{{ form.email }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-600 dark:text-gray-400">Circle:</dt>
|
||||
<dt class="text-[--ui-text-muted]">Circle:</dt>
|
||||
<dd class="font-medium capitalize">{{ form.circle }}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<dt class="text-gray-600 dark:text-gray-400">
|
||||
Contribution:
|
||||
</dt>
|
||||
<dt class="text-[--ui-text-muted]">Contribution:</dt>
|
||||
<dd class="font-medium">{{ selectedTier.label }}</dd>
|
||||
</div>
|
||||
<div v-if="customerCode" class="flex justify-between">
|
||||
<dt class="text-gray-600 dark:text-gray-400">Member ID:</dt>
|
||||
<dt class="text-[--ui-text-muted]">Member ID:</dt>
|
||||
<dd class="font-medium">{{ customerCode }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-4">
|
||||
<p class="text-[--ui-text-muted] mb-4">
|
||||
We've sent a confirmation email to {{ form.email }} with your
|
||||
membership details.
|
||||
</p>
|
||||
|
||||
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 mb-8">
|
||||
<p class="text-gray-700 dark:text-gray-300 text-center">
|
||||
<div class="bg-[--ui-bg] rounded-lg p-4 mb-8">
|
||||
<p class="text-[--ui-text] text-center">
|
||||
You will be automatically redirected to your dashboard in a few
|
||||
seconds...
|
||||
</p>
|
||||
|
|
@ -339,14 +326,14 @@
|
|||
</section>
|
||||
|
||||
<!-- How Ghost Guild Works -->
|
||||
<section class="py-20 bg-gray-50 dark:bg-gray-800">
|
||||
<section class="py-20 bg-[--ui-bg-elevated]">
|
||||
<UContainer>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
|
||||
How Ghost Guild Works
|
||||
</h2>
|
||||
<p class="text-lg text-gray-700 dark:text-gray-300">
|
||||
<p class="text-lg text-[--ui-text]">
|
||||
Every member gets everything. Your circle helps you find relevant
|
||||
content and peers. Your contribution helps sustain our solidarity
|
||||
economy.
|
||||
|
|
@ -355,13 +342,11 @@
|
|||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<!-- Full Access -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||
<h3
|
||||
class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"
|
||||
>
|
||||
<div class="bg-[--ui-bg] rounded-xl p-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-[--ui-text]">
|
||||
Full Access
|
||||
</h3>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<ul class="text-[--ui-text] space-y-2">
|
||||
<li>Complete resource library</li>
|
||||
<li>All workshops and events</li>
|
||||
<li>Slack community</li>
|
||||
|
|
@ -371,13 +356,11 @@
|
|||
</div>
|
||||
|
||||
<!-- Circle-Specific Guidance -->
|
||||
<div class="bg-white dark:bg-gray-900 rounded-xl p-6">
|
||||
<h3
|
||||
class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"
|
||||
>
|
||||
<div class="bg-[--ui-bg] rounded-xl p-6">
|
||||
<h3 class="text-xl font-semibold mb-4 text-[--ui-text]">
|
||||
Circle-Specific Guidance
|
||||
</h3>
|
||||
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
|
||||
<ul class="text-[--ui-text] space-y-2">
|
||||
<li>Curated resources for your stage</li>
|
||||
<li>Connection with peers on similar journeys</li>
|
||||
<li>Relevant workshop recommendations</li>
|
||||
|
|
@ -390,25 +373,23 @@
|
|||
</section>
|
||||
|
||||
<!-- How to Join -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<section class="py-20 bg-[--ui-bg]">
|
||||
<UContainer>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="space-y-8">
|
||||
<div class="flex items-start gap-6">
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
class="w-12 h-12 bg-gray-900 dark:bg-white rounded-full flex items-center justify-center text-white dark:text-gray-900 font-bold text-xl"
|
||||
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
|
||||
>
|
||||
1
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3
|
||||
class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
|
||||
>
|
||||
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
|
||||
Pick your circle
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<p class="text-[--ui-text]">
|
||||
Where are you in your co-op journey? Select based on where you
|
||||
are in your cooperative journey - exploring, building, or
|
||||
practicing. Not sure? Start with Community.
|
||||
|
|
@ -419,18 +400,16 @@
|
|||
<div class="flex items-start gap-6">
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
class="w-12 h-12 bg-gray-900 dark:bg-white rounded-full flex items-center justify-center text-white dark:text-gray-900 font-bold text-xl"
|
||||
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
|
||||
>
|
||||
2
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3
|
||||
class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
|
||||
>
|
||||
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
|
||||
Choose your contribution
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<p class="text-[--ui-text]">
|
||||
What can you afford? ($0-50+/month) Choose based on your
|
||||
financial capacity. From $0 for those who need support to $50+
|
||||
for those who can sponsor others. You can adjust anytime.
|
||||
|
|
@ -441,18 +420,16 @@
|
|||
<div class="flex items-start gap-6">
|
||||
<div class="flex-shrink-0">
|
||||
<div
|
||||
class="w-12 h-12 bg-gray-900 dark:bg-white rounded-full flex items-center justify-center text-white dark:text-gray-900 font-bold text-xl"
|
||||
class="w-12 h-12 bg-neutral-900 rounded-full flex items-center justify-center text-neutral-50 font-bold text-xl"
|
||||
>
|
||||
3
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h3
|
||||
class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
|
||||
>
|
||||
<h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
|
||||
Join the community
|
||||
</h3>
|
||||
<p class="text-gray-700 dark:text-gray-300">
|
||||
<p class="text-[--ui-text]">
|
||||
Get instant access to everything. Fill out your profile, agree
|
||||
to our community guidelines, and complete payment (if
|
||||
applicable). You'll get instant access to our community.
|
||||
|
|
|
|||
|
|
@ -9,18 +9,20 @@
|
|||
/>
|
||||
|
||||
<!-- Login Form -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<section class="py-20 bg-[--ui-bg]">
|
||||
<UContainer class="max-w-md">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4">
|
||||
<h2 class="text-3xl font-bold text-primary-500 mb-4">
|
||||
Passwordless Login
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
<p class="text-[--ui-text-muted]">
|
||||
Enter your email to receive a secure login link
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800">
|
||||
<div
|
||||
class="bg-[--ui-bg-elevated] rounded-2xl p-8 shadow-xl border border-primary-200"
|
||||
>
|
||||
<UForm :state="loginForm" class="space-y-6" @submit="handleLogin">
|
||||
<!-- Email Field -->
|
||||
<UFormField label="Email Address" name="email" required>
|
||||
|
|
@ -34,7 +36,7 @@
|
|||
</UFormField>
|
||||
|
||||
<!-- Passwordless Info -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div class="bg-primary-50 p-4 rounded-lg border border-primary-200">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="space-y-1 flex-shrink-0 mt-1">
|
||||
<div class="w-2 h-2 bg-blue-500 rounded-full" />
|
||||
|
|
@ -46,8 +48,9 @@
|
|||
<div class="h-1 bg-blue-200 rounded-full w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-blue-700 dark:text-blue-300 text-sm mt-3">
|
||||
We'll send you a secure login link via email. No password needed!
|
||||
<p class="text-primary-700 text-sm mt-3">
|
||||
We'll send you a secure login link via email. No password
|
||||
needed!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -66,23 +69,31 @@
|
|||
</UForm>
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
<div v-if="loginSuccess" class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<p class="text-green-700 dark:text-green-300 text-center">
|
||||
✅ Magic link sent! Check your email and click the link to sign in.
|
||||
<div
|
||||
v-if="loginSuccess"
|
||||
class="mt-6 p-4 bg-green-50 rounded-lg border border-green-200"
|
||||
>
|
||||
<p class="text-green-700 text-center">
|
||||
✅ Magic link sent! Check your email and click the link to sign
|
||||
in.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="loginError" class="mt-6 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<p class="text-red-700 dark:text-red-300 text-center">
|
||||
❌ {{ loginError }}
|
||||
</p>
|
||||
<div
|
||||
v-if="loginError"
|
||||
class="mt-6 p-4 bg-red-50 rounded-lg border border-red-200"
|
||||
>
|
||||
<p class="text-red-700 text-center">❌ {{ loginError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Sign Up Link -->
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-gray-600 dark:text-gray-400">
|
||||
<p class="text-[--ui-text-muted]">
|
||||
Don't have an account?
|
||||
<NuxtLink to="/join" class="text-blue-600 dark:text-blue-400 hover:underline font-medium">
|
||||
<NuxtLink
|
||||
to="/join"
|
||||
class="text-primary-500 hover:underline font-medium"
|
||||
>
|
||||
Join Ghost Guild
|
||||
</NuxtLink>
|
||||
</p>
|
||||
|
|
@ -92,19 +103,25 @@
|
|||
</section>
|
||||
|
||||
<!-- Forgot Password -->
|
||||
<section id="forgot-password" class="py-20 bg-gray-50 dark:bg-gray-800">
|
||||
<section id="forgot-password" class="py-20 bg-neutral-50">
|
||||
<UContainer class="max-w-md">
|
||||
<div class="text-center mb-12">
|
||||
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-4">
|
||||
<h2 class="text-3xl font-bold text-primary-500 mb-4">
|
||||
Forgot Password
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-300">
|
||||
<p class="text-[--ui-text-muted]">
|
||||
Enter your email to receive a password reset link
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800">
|
||||
<UForm :state="forgotPasswordForm" class="space-y-6" @submit="handleForgotPassword">
|
||||
<div
|
||||
class="bg-[--ui-bg-elevated] rounded-2xl p-8 shadow-xl border border-primary-200"
|
||||
>
|
||||
<UForm
|
||||
:state="forgotPasswordForm"
|
||||
class="space-y-6"
|
||||
@submit="handleForgotPassword"
|
||||
>
|
||||
<!-- Email Field -->
|
||||
<UFormField label="Email Address" name="email" required>
|
||||
<UInput
|
||||
|
|
@ -117,7 +134,7 @@
|
|||
</UFormField>
|
||||
|
||||
<!-- Reset Instructions -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<div class="bg-primary-50 p-4 rounded-lg border border-primary-200">
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="space-y-1 flex-shrink-0 mt-1">
|
||||
<div class="w-2 h-2 bg-blue-500 rounded-full" />
|
||||
|
|
@ -129,8 +146,9 @@
|
|||
<div class="h-1 bg-blue-200 rounded-full w-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-blue-700 dark:text-blue-300 text-sm mt-3">
|
||||
We'll send you a secure link to reset your password. Check your email inbox and spam folder.
|
||||
<p class="text-primary-700 text-sm mt-3">
|
||||
We'll send you a secure link to reset your password. Check your
|
||||
email inbox and spam folder.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -150,36 +168,39 @@
|
|||
</UForm>
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
<div v-if="resetSuccess" class="mt-6 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800">
|
||||
<p class="text-green-700 dark:text-green-300 text-center">
|
||||
<div
|
||||
v-if="resetSuccess"
|
||||
class="mt-6 p-4 bg-green-50 rounded-lg border border-green-200"
|
||||
>
|
||||
<p class="text-green-700 text-center">
|
||||
✅ Password reset link sent! Check your email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="resetError" class="mt-6 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<p class="text-red-700 dark:text-red-300 text-center">
|
||||
❌ {{ resetError }}
|
||||
</p>
|
||||
<div
|
||||
v-if="resetError"
|
||||
class="mt-6 p-4 bg-red-50 rounded-lg border border-red-200"
|
||||
>
|
||||
<p class="text-red-700 text-center">❌ {{ resetError }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Sign In CTA -->
|
||||
<section class="py-20 bg-white dark:bg-gray-900">
|
||||
<section class="py-20 bg-[--ui-bg]">
|
||||
<UContainer>
|
||||
<div class="text-center max-w-2xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
|
||||
Sign In
|
||||
</h2>
|
||||
<h2 class="text-3xl font-bold text-primary-500 mb-8">Sign In</h2>
|
||||
|
||||
<div class="space-y-4 mb-8">
|
||||
<div class="h-2 bg-blue-500 rounded-full w-64 mx-auto" />
|
||||
<div class="h-2 bg-blue-300 rounded-full w-48 mx-auto" />
|
||||
</div>
|
||||
|
||||
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ready to access your account and connect with the community?
|
||||
<p class="text-lg text-[--ui-text-muted] mb-8">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ready to
|
||||
access your account and connect with the community?
|
||||
</p>
|
||||
|
||||
<UButton
|
||||
|
|
@ -195,10 +216,10 @@
|
|||
</section>
|
||||
|
||||
<!-- Access Your Dashboard -->
|
||||
<section class="py-20 bg-blue-50 dark:bg-blue-900/20">
|
||||
<section class="py-20 bg-primary-50">
|
||||
<UContainer>
|
||||
<div class="text-center max-w-3xl mx-auto">
|
||||
<h2 class="text-3xl font-bold text-blue-600 dark:text-blue-400 mb-8">
|
||||
<h2 class="text-3xl font-bold text-primary-500 mb-8">
|
||||
Access Your Dashboard
|
||||
</h2>
|
||||
|
||||
|
|
@ -209,53 +230,59 @@
|
|||
<div class="h-2 bg-blue-200 rounded-full w-full max-w-xs mx-auto" />
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-lg border border-blue-200 dark:border-blue-800 mb-8">
|
||||
<p class="text-lg text-gray-600 dark:text-gray-300 mb-6">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Once you're logged in, you'll have access to:
|
||||
<div
|
||||
class="bg-[--ui-bg-elevated] rounded-2xl p-8 shadow-lg border border-primary-200 mb-8"
|
||||
>
|
||||
<p class="text-lg text-[--ui-text-muted] mb-6">
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Once
|
||||
you're logged in, you'll have access to:
|
||||
</p>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 text-left">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-blue-500 rounded-full" />
|
||||
<span class="text-gray-700 dark:text-gray-300">Community forums and discussions</span>
|
||||
<span class="text-[--ui-text]"
|
||||
>Community forums and discussions</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-blue-400 rounded-full" />
|
||||
<span class="text-gray-700 dark:text-gray-300">Member directory and networking</span>
|
||||
<span class="text-[--ui-text]"
|
||||
>Member directory and networking</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-blue-300 rounded-full" />
|
||||
<span class="text-gray-700 dark:text-gray-300">Educational resources and workshops</span>
|
||||
<span class="text-[--ui-text]"
|
||||
>Educational resources and workshops</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-emerald-500 rounded-full" />
|
||||
<span class="text-gray-700 dark:text-gray-300">Cooperative development tools</span>
|
||||
<span class="text-[--ui-text]"
|
||||
>Cooperative development tools</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-emerald-400 rounded-full" />
|
||||
<span class="text-gray-700 dark:text-gray-300">Mentorship opportunities</span>
|
||||
<span class="text-[--ui-text]">Mentorship opportunities</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 bg-emerald-300 rounded-full" />
|
||||
<span class="text-gray-700 dark:text-gray-300">Project collaboration spaces</span>
|
||||
<span class="text-[--ui-text]"
|
||||
>Project collaboration spaces</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-gray-600 dark:text-gray-300 mb-4">
|
||||
New to Ghost Guild?
|
||||
</p>
|
||||
<UButton
|
||||
to="/join"
|
||||
variant="outline"
|
||||
size="lg"
|
||||
class="px-8"
|
||||
>
|
||||
<p class="text-[--ui-text-muted] mb-4">New to Ghost Guild?</p>
|
||||
<UButton to="/join" variant="outline" size="lg" class="px-8">
|
||||
Create Your Account
|
||||
</UButton>
|
||||
</div>
|
||||
|
|
@ -266,112 +293,112 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref, computed } from 'vue'
|
||||
import { reactive, ref, computed } from "vue";
|
||||
|
||||
// Login form state
|
||||
const loginForm = reactive({
|
||||
email: ''
|
||||
})
|
||||
email: "",
|
||||
});
|
||||
|
||||
// Forgot password form state
|
||||
const forgotPasswordForm = reactive({
|
||||
email: ''
|
||||
})
|
||||
email: "",
|
||||
});
|
||||
|
||||
// UI state
|
||||
const isLoggingIn = ref(false)
|
||||
const isResettingPassword = ref(false)
|
||||
const loginSuccess = ref(false)
|
||||
const loginError = ref('')
|
||||
const resetSuccess = ref(false)
|
||||
const resetError = ref('')
|
||||
const isLoggingIn = ref(false);
|
||||
const isResettingPassword = ref(false);
|
||||
const loginSuccess = ref(false);
|
||||
const loginError = ref("");
|
||||
const resetSuccess = ref(false);
|
||||
const resetError = ref("");
|
||||
|
||||
// Form validation
|
||||
const isLoginFormValid = computed(() => {
|
||||
return loginForm.email && loginForm.email.includes('@')
|
||||
})
|
||||
return loginForm.email && loginForm.email.includes("@");
|
||||
});
|
||||
|
||||
// Login handler
|
||||
const handleLogin = async () => {
|
||||
if (isLoggingIn.value) return
|
||||
if (isLoggingIn.value) return;
|
||||
|
||||
isLoggingIn.value = true
|
||||
loginError.value = ''
|
||||
loginSuccess.value = false
|
||||
isLoggingIn.value = true;
|
||||
loginError.value = "";
|
||||
loginSuccess.value = false;
|
||||
|
||||
try {
|
||||
// Call the passwordless login API
|
||||
const response = await $fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
const response = await $fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
email: loginForm.email
|
||||
}
|
||||
})
|
||||
email: loginForm.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
loginSuccess.value = true
|
||||
loginError.value = ''
|
||||
loginSuccess.value = true;
|
||||
loginError.value = "";
|
||||
|
||||
// Clear the form
|
||||
loginForm.email = ''
|
||||
loginForm.email = "";
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
console.error("Login error:", err);
|
||||
|
||||
// Handle different error types
|
||||
if (err.statusCode === 404) {
|
||||
loginError.value = 'No account found with that email address. Please check your email or create an account.'
|
||||
loginError.value =
|
||||
"No account found with that email address. Please check your email or create an account.";
|
||||
} else if (err.statusCode === 500) {
|
||||
loginError.value = 'Failed to send login email. Please try again later.'
|
||||
loginError.value = "Failed to send login email. Please try again later.";
|
||||
} else {
|
||||
loginError.value = err.statusMessage || 'Something went wrong. Please try again.'
|
||||
loginError.value =
|
||||
err.statusMessage || "Something went wrong. Please try again.";
|
||||
}
|
||||
} finally {
|
||||
isLoggingIn.value = false
|
||||
isLoggingIn.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Forgot password handler
|
||||
const handleForgotPassword = async () => {
|
||||
if (isResettingPassword.value) return
|
||||
if (isResettingPassword.value) return;
|
||||
|
||||
isResettingPassword.value = true
|
||||
resetError.value = ''
|
||||
resetSuccess.value = false
|
||||
isResettingPassword.value = true;
|
||||
resetError.value = "";
|
||||
resetSuccess.value = false;
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
resetSuccess.value = true
|
||||
resetSuccess.value = true;
|
||||
|
||||
// Reset form after success
|
||||
setTimeout(() => {
|
||||
forgotPasswordForm.email = ''
|
||||
resetSuccess.value = false
|
||||
}, 5000)
|
||||
|
||||
forgotPasswordForm.email = "";
|
||||
resetSuccess.value = false;
|
||||
}, 5000);
|
||||
} catch (err) {
|
||||
console.error('Password reset error:', err)
|
||||
resetError.value = 'Failed to send reset email. Please try again.'
|
||||
console.error("Password reset error:", err);
|
||||
resetError.value = "Failed to send reset email. Please try again.";
|
||||
} finally {
|
||||
isResettingPassword.value = false
|
||||
isResettingPassword.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Scroll functions
|
||||
const scrollToLoginForm = () => {
|
||||
const formSection = document.querySelector('form')
|
||||
const formSection = document.querySelector("form");
|
||||
if (formSection) {
|
||||
formSection.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
formSection.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToForgotPassword = () => {
|
||||
const forgotSection = document.getElementById('forgot-password')
|
||||
const forgotSection = document.getElementById("forgot-password");
|
||||
if (forgotSection) {
|
||||
forgotSection.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
forgotSection.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
@ -18,7 +18,7 @@
|
|||
<div
|
||||
class="w-8 h-8 border-4 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-stone-300">Loading your dashboard...</p>
|
||||
<p class="text-ghost-300">Loading your dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -36,10 +36,10 @@
|
|||
<template #header>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-bold text-stone-100 ethereal-text">
|
||||
<h1 class="text-2xl font-bold text-ghost-100 ethereal-text">
|
||||
Welcome to Ghost Guild, {{ memberData?.name }}!
|
||||
</h1>
|
||||
<p class="text-stone-300 mt-2">
|
||||
<p class="text-ghost-300 mt-2">
|
||||
Your membership is active and you're part of our cooperative
|
||||
community.
|
||||
</p>
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
</div>
|
||||
<div v-else class="flex-shrink-0">
|
||||
<div
|
||||
class="w-16 h-16 bg-ghost-700 border border-ghost-600 flex items-center justify-center text-stone-200 font-bold text-xl"
|
||||
class="w-16 h-16 bg-ghost-700 border border-ghost-600 flex items-center justify-center text-ghost-200 font-bold text-xl"
|
||||
>
|
||||
{{ memberData?.name?.charAt(0)?.toUpperCase() }}
|
||||
</div>
|
||||
|
|
@ -63,13 +63,13 @@
|
|||
|
||||
<div class="flex flex-wrap gap-4 text-sm">
|
||||
<div class="bg-ghost-800 border border-ghost-600 px-4 py-2">
|
||||
<span class="text-stone-400">Circle:</span>
|
||||
<span class="text-ghost-400">Circle:</span>
|
||||
<span class="font-medium text-whisper-300 ml-1 capitalize">{{
|
||||
memberData?.circle
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="bg-ghost-800 border border-ghost-600 px-4 py-2">
|
||||
<span class="text-stone-400">Contribution:</span>
|
||||
<span class="text-ghost-400">Contribution:</span>
|
||||
<span class="font-medium text-whisper-300 ml-1"
|
||||
>${{ memberData?.contributionTier }} CAD/month</span
|
||||
>
|
||||
|
|
@ -86,28 +86,16 @@
|
|||
}"
|
||||
>
|
||||
<template #header>
|
||||
<h2 class="text-xl font-bold text-stone-100 ethereal-text">
|
||||
<h2 class="text-xl font-bold text-ghost-100 ethereal-text">
|
||||
Quick Links
|
||||
</h2>
|
||||
</template>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
<UButton
|
||||
to="/member/my-updates"
|
||||
variant="outline"
|
||||
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
|
||||
block
|
||||
>
|
||||
<template #leading>
|
||||
<Icon name="heroicons:pencil-square" class="w-5 h-5" />
|
||||
</template>
|
||||
Post an Update
|
||||
</UButton>
|
||||
|
||||
<UButton
|
||||
disabled
|
||||
variant="outline"
|
||||
class="border-ghost-600 text-stone-500 cursor-not-allowed justify-start"
|
||||
class="border-ghost-600 text-ghost-500 cursor-not-allowed justify-start"
|
||||
block
|
||||
title="Coming soon"
|
||||
>
|
||||
|
|
@ -120,7 +108,7 @@
|
|||
<UButton
|
||||
disabled
|
||||
variant="outline"
|
||||
class="border-ghost-600 text-stone-500 cursor-not-allowed justify-start"
|
||||
class="border-ghost-600 text-ghost-500 cursor-not-allowed justify-start"
|
||||
block
|
||||
title="Coming soon"
|
||||
>
|
||||
|
|
@ -133,7 +121,7 @@
|
|||
<UButton
|
||||
disabled
|
||||
variant="outline"
|
||||
class="border-ghost-600 text-stone-500 cursor-not-allowed justify-start"
|
||||
class="border-ghost-600 text-ghost-500 cursor-not-allowed justify-start"
|
||||
block
|
||||
title="Coming soon"
|
||||
>
|
||||
|
|
@ -146,7 +134,7 @@
|
|||
<UButton
|
||||
to="/member/profile"
|
||||
variant="outline"
|
||||
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
|
||||
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500 justify-start"
|
||||
block
|
||||
>
|
||||
<template #leading>
|
||||
|
|
@ -179,10 +167,10 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<h3 class="text-lg font-semibold mb-2 text-stone-100">
|
||||
<h3 class="text-lg font-semibold mb-2 text-ghost-100">
|
||||
Upcoming Events
|
||||
</h3>
|
||||
<p class="text-stone-300 mb-4">
|
||||
<p class="text-ghost-300 mb-4">
|
||||
Discover and register for community events and workshops.
|
||||
</p>
|
||||
|
||||
|
|
@ -191,7 +179,7 @@
|
|||
to="/events"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
|
||||
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500"
|
||||
>
|
||||
View Events
|
||||
</UButton>
|
||||
|
|
@ -217,8 +205,8 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<h3 class="text-lg font-semibold mb-2 text-stone-100">Community</h3>
|
||||
<p class="text-stone-300 mb-4">
|
||||
<h3 class="text-lg font-semibold mb-2 text-ghost-100">Community</h3>
|
||||
<p class="text-ghost-300 mb-4">
|
||||
Connect with other members in your circle and beyond.
|
||||
</p>
|
||||
|
||||
|
|
@ -227,7 +215,7 @@
|
|||
to="/members"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
|
||||
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500"
|
||||
>
|
||||
Browse Members
|
||||
</UButton>
|
||||
|
|
@ -253,10 +241,10 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<h3 class="text-lg font-semibold mb-2 text-stone-100">
|
||||
<h3 class="text-lg font-semibold mb-2 text-ghost-100">
|
||||
Account Settings
|
||||
</h3>
|
||||
<p class="text-stone-300 mb-4">
|
||||
<p class="text-ghost-300 mb-4">
|
||||
Manage your profile and membership settings.
|
||||
</p>
|
||||
|
||||
|
|
@ -265,7 +253,7 @@
|
|||
to="/member/profile#account"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
|
||||
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500"
|
||||
>
|
||||
Manage Account
|
||||
</UButton>
|
||||
|
|
@ -283,14 +271,14 @@
|
|||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-stone-100 ethereal-text">
|
||||
<h2 class="text-xl font-bold text-ghost-100 ethereal-text">
|
||||
Your Upcoming Events
|
||||
</h2>
|
||||
<UButton
|
||||
to="/events"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-stone-300 hover:text-stone-100"
|
||||
class="text-ghost-300 hover:text-ghost-100"
|
||||
>
|
||||
Browse All Events
|
||||
</UButton>
|
||||
|
|
@ -334,10 +322,10 @@
|
|||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h3 class="font-semibold text-stone-100 mb-1">
|
||||
<h3 class="font-semibold text-ghost-100 mb-1">
|
||||
{{ evt.title }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-4 text-sm text-stone-400">
|
||||
<div class="flex items-center gap-4 text-sm text-ghost-400">
|
||||
<span class="flex items-center gap-1">
|
||||
<Icon name="heroicons:calendar" class="w-4 h-4" />
|
||||
{{ formatEventDate(evt.startDate) }}
|
||||
|
|
@ -351,7 +339,7 @@
|
|||
<div class="flex-shrink-0">
|
||||
<Icon
|
||||
name="heroicons:chevron-right"
|
||||
class="w-5 h-5 text-stone-500"
|
||||
class="w-5 h-5 text-ghost-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -361,108 +349,20 @@
|
|||
<div v-else class="text-center py-8">
|
||||
<Icon
|
||||
name="heroicons:calendar-days"
|
||||
class="w-12 h-12 text-stone-600 mx-auto mb-3"
|
||||
class="w-12 h-12 text-ghost-600 mx-auto mb-3"
|
||||
/>
|
||||
<p class="text-stone-400 mb-4">
|
||||
<p class="text-ghost-400 mb-4">
|
||||
You haven't registered for any upcoming events
|
||||
</p>
|
||||
<UButton
|
||||
to="/events"
|
||||
size="sm"
|
||||
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
|
||||
class="border-ghost-600 text-ghost-200 hover:bg-ghost-800 hover:border-whisper-500"
|
||||
>
|
||||
Browse Events
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
|
||||
<!-- Community Pulse - Recent Updates -->
|
||||
<UCard
|
||||
class="sparkle-field"
|
||||
:ui="{
|
||||
root: 'bg-ghost-900 border border-ghost-700',
|
||||
header: 'border-b border-ghost-700 bg-ghost-900',
|
||||
body: 'bg-ghost-900',
|
||||
}"
|
||||
>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-bold text-stone-100 ethereal-text">
|
||||
Community Pulse
|
||||
</h2>
|
||||
<UButton
|
||||
to="/updates"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="text-stone-300 hover:text-stone-100"
|
||||
>
|
||||
View All
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loadingUpdates" class="text-center py-8">
|
||||
<div
|
||||
class="w-6 h-6 border-2 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="recentUpdates.length" class="space-y-4">
|
||||
<div
|
||||
v-for="update in recentUpdates"
|
||||
:key="update._id"
|
||||
class="border-l-2 border-ghost-600 pl-4 py-2"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<img
|
||||
v-if="
|
||||
update.author?.avatar && isValidAvatar(update.author.avatar)
|
||||
"
|
||||
:src="`/ghosties/Ghost-${capitalize(update.author.avatar)}.png`"
|
||||
:alt="update.author.name"
|
||||
class="w-8 h-8 flex-shrink-0"
|
||||
/>
|
||||
<div
|
||||
v-else-if="update.author?.name"
|
||||
class="w-8 h-8 bg-ghost-700 border border-ghost-600 flex items-center justify-center text-stone-200 text-xs font-bold flex-shrink-0"
|
||||
>
|
||||
{{ update.author.name.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2 mb-1">
|
||||
<span class="font-semibold text-stone-100 text-sm">
|
||||
{{ update.author?.name }}
|
||||
</span>
|
||||
<span class="text-xs text-stone-500">
|
||||
{{ formatTimeAgo(update.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-stone-300 text-sm line-clamp-2">
|
||||
{{ update.content }}
|
||||
</p>
|
||||
<NuxtLink
|
||||
:to="`/updates/${update._id}`"
|
||||
class="text-xs text-whisper-400 hover:text-whisper-300 mt-1 inline-block"
|
||||
>
|
||||
Read more
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-8">
|
||||
<p class="text-stone-400 mb-4">No community updates yet</p>
|
||||
<UButton
|
||||
to="/updates/new"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="border-ghost-600 text-stone-200 hover:bg-ghost-800 hover:border-whisper-500"
|
||||
>
|
||||
Post the First Update
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</UContainer>
|
||||
</div>
|
||||
|
|
@ -471,8 +371,6 @@
|
|||
<script setup>
|
||||
const { memberData, checkMemberStatus } = useAuth();
|
||||
|
||||
const recentUpdates = ref([]);
|
||||
const loadingUpdates = ref(false);
|
||||
const registeredEvents = ref([]);
|
||||
const loadingEvents = ref(false);
|
||||
|
||||
|
|
@ -506,21 +404,6 @@ const { pending: authPending } = await useLazyAsyncData(
|
|||
},
|
||||
);
|
||||
|
||||
// Load recent updates
|
||||
const loadRecentUpdates = async () => {
|
||||
loadingUpdates.value = true;
|
||||
try {
|
||||
const response = await $fetch("/api/updates", {
|
||||
params: { limit: 5, skip: 0 },
|
||||
});
|
||||
recentUpdates.value = response.updates;
|
||||
} catch (error) {
|
||||
console.error("Failed to load recent updates:", error);
|
||||
} finally {
|
||||
loadingUpdates.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Load registered events
|
||||
const loadRegisteredEvents = async () => {
|
||||
console.log(
|
||||
|
|
@ -575,23 +458,6 @@ const capitalize = (str) => {
|
|||
.join("-");
|
||||
};
|
||||
|
||||
const formatTimeAgo = (date) => {
|
||||
const now = new Date();
|
||||
const updateDate = new Date(date);
|
||||
const diffInSeconds = Math.floor((now - updateDate) / 1000);
|
||||
|
||||
if (diffInSeconds < 60) return "just now";
|
||||
if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)}m ago`;
|
||||
if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)}h ago`;
|
||||
if (diffInSeconds < 604800)
|
||||
return `${Math.floor(diffInSeconds / 86400)}d ago`;
|
||||
|
||||
return updateDate.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// Helper functions for event display
|
||||
const getEventImageUrl = (featureImage) => {
|
||||
if (!featureImage) return "";
|
||||
|
|
@ -626,7 +492,6 @@ const formatEventTime = (dateString) => {
|
|||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadRecentUpdates();
|
||||
loadRegisteredEvents();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@
|
|||
<UContainer class="px-4">
|
||||
<!-- Stats -->
|
||||
<div v-if="!pending" class="mb-8 flex items-center justify-between">
|
||||
<div class="text-stone-300">
|
||||
<span class="text-2xl font-bold text-stone-100">{{ total }}</span>
|
||||
<div class="text-ghost-300">
|
||||
<span class="text-2xl font-bold text-ghost-100">{{ total }}</span>
|
||||
{{ total === 1 ? "update" : "updates" }} posted
|
||||
</div>
|
||||
<UButton to="/updates/new" icon="i-lucide-plus"> New Update </UButton>
|
||||
|
|
@ -25,9 +25,9 @@
|
|||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
||||
class="w-8 h-8 border-4 border-ghost-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-stone-400">Loading your updates...</p>
|
||||
<p class="text-ghost-400">Loading your updates...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
class="text-stone-600"
|
||||
class="text-ghost-600"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
|
|
@ -72,10 +72,10 @@
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-stone-300 mb-2">
|
||||
<h3 class="text-lg font-medium text-ghost-300 mb-2">
|
||||
No updates yet
|
||||
</h3>
|
||||
<p class="text-stone-400 mb-6">
|
||||
<p class="text-ghost-400 mb-6">
|
||||
Share your first update with the community
|
||||
</p>
|
||||
<UButton to="/updates/new" icon="i-lucide-plus">
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,7 @@
|
|||
<UContainer class="px-4">
|
||||
<!-- Search and Filters -->
|
||||
<div class="mb-8 space-y-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Search -->
|
||||
<div class="md:col-span-2">
|
||||
<UInput
|
||||
|
|
@ -26,21 +26,28 @@
|
|||
<!-- Circle Filter -->
|
||||
<USelect
|
||||
v-model="selectedCircle"
|
||||
:options="circleOptions"
|
||||
placeholder="All Circles"
|
||||
:items="circleOptions"
|
||||
size="lg"
|
||||
@change="loadMembers"
|
||||
@update:model-value="loadMembers"
|
||||
/>
|
||||
|
||||
<!-- Peer Support Filter -->
|
||||
<USelect
|
||||
v-model="peerSupportFilter"
|
||||
:items="peerSupportOptions"
|
||||
size="lg"
|
||||
@update:model-value="loadMembers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Skills Filter -->
|
||||
<div v-if="availableSkills.length > 0">
|
||||
<div v-if="availableSkills && availableSkills.length > 0">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="text-sm text-stone-400 mr-2 self-center"
|
||||
<span class="text-sm text-ghost-400 mr-2 self-center"
|
||||
>Filter by skill:</span
|
||||
>
|
||||
<button
|
||||
v-for="skill in availableSkills.slice(
|
||||
v-for="skill in (availableSkills || []).slice(
|
||||
0,
|
||||
showAllSkills ? undefined : 10,
|
||||
)"
|
||||
|
|
@ -50,14 +57,14 @@
|
|||
:class="
|
||||
selectedSkills.includes(skill)
|
||||
? 'bg-purple-500/20 text-purple-300 border-purple-500/50'
|
||||
: 'bg-stone-800/50 text-stone-400 border-stone-700 hover:border-stone-600'
|
||||
: 'bg-ghost-800/50 text-ghost-400 border-ghost-700 hover:border-ghost-600'
|
||||
"
|
||||
@click="toggleSkill(skill)"
|
||||
>
|
||||
{{ skill }}
|
||||
</button>
|
||||
<button
|
||||
v-if="availableSkills.length > 10"
|
||||
v-if="availableSkills && availableSkills.length > 10"
|
||||
type="button"
|
||||
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
|
||||
@click="showAllSkills = !showAllSkills"
|
||||
|
|
@ -71,12 +78,55 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Peer Support Topics Filter -->
|
||||
<div v-if="availableTopics && availableTopics.length > 0">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span class="text-sm text-ghost-400 mr-2 self-center"
|
||||
>Filter by peer support topic:</span
|
||||
>
|
||||
<button
|
||||
v-for="topic in (availableTopics || []).slice(
|
||||
0,
|
||||
showAllTopics ? undefined : 10,
|
||||
)"
|
||||
:key="topic"
|
||||
type="button"
|
||||
class="px-3 py-1 rounded-full text-sm transition-all border"
|
||||
:class="
|
||||
selectedTopics.includes(topic)
|
||||
? 'bg-purple-500/20 text-purple-300 border-purple-500/50'
|
||||
: 'bg-ghost-800/50 text-ghost-400 border-ghost-700 hover:border-ghost-600'
|
||||
"
|
||||
@click="toggleTopic(topic)"
|
||||
>
|
||||
{{ topic }}
|
||||
</button>
|
||||
<button
|
||||
v-if="availableTopics && availableTopics.length > 10"
|
||||
type="button"
|
||||
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
|
||||
@click="showAllTopics = !showAllTopics"
|
||||
>
|
||||
{{
|
||||
showAllTopics
|
||||
? "Show less"
|
||||
: `+${availableTopics.length - 10} more`
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Filters -->
|
||||
<div
|
||||
v-if="selectedCircle || selectedSkills.length > 0"
|
||||
class="flex items-center gap-2 text-sm"
|
||||
v-if="
|
||||
selectedCircle ||
|
||||
peerSupportFilter ||
|
||||
selectedSkills.length > 0 ||
|
||||
selectedTopics.length > 0
|
||||
"
|
||||
class="flex items-center gap-2 text-sm flex-wrap"
|
||||
>
|
||||
<span class="text-stone-400">Active filters:</span>
|
||||
<span class="text-ghost-400">Active filters:</span>
|
||||
<span
|
||||
v-if="selectedCircle"
|
||||
class="px-2 py-1 bg-purple-500/20 text-purple-300 rounded-full border border-purple-500/30 flex items-center gap-1"
|
||||
|
|
@ -90,8 +140,21 @@
|
|||
×
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-if="peerSupportFilter"
|
||||
class="px-2 py-1 bg-purple-500/20 text-purple-300 rounded-full border border-purple-500/30 flex items-center gap-1"
|
||||
>
|
||||
Offering Peer Support
|
||||
<button
|
||||
v-if="selectedSkills.length > 0"
|
||||
type="button"
|
||||
class="hover:text-purple-200"
|
||||
@click="clearPeerSupportFilter"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
|
||||
type="button"
|
||||
class="text-purple-400 hover:text-purple-300"
|
||||
@click="clearAllFilters"
|
||||
|
|
@ -110,75 +173,88 @@
|
|||
<div
|
||||
class="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-stone-400">Loading members...</p>
|
||||
<p class="text-ghost-400">Loading members...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Members List -->
|
||||
<div v-else-if="members.length > 0">
|
||||
<div class="mb-4 text-stone-400 text-sm">
|
||||
<div class="mb-4 text-ghost-400 text-sm">
|
||||
{{ totalCount }} {{ totalCount === 1 ? "member" : "members" }} found
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-for="member in members"
|
||||
:key="member._id"
|
||||
class="backdrop-blur-sm bg-stone-900/50 border border-stone-700/50 rounded-lg p-4 hover:border-purple-500/50 transition-all group flex items-center gap-4"
|
||||
class="backdrop-blur-sm bg-ghost-900/50 border border-ghost-700/50 rounded-lg p-6 hover:border-purple-500/50 transition-all group"
|
||||
>
|
||||
<!-- Header Section -->
|
||||
<div class="flex items-start gap-4 mb-4">
|
||||
<!-- Avatar -->
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg bg-stone-800 border border-stone-700 flex items-center justify-center flex-shrink-0 group-hover:border-purple-500/50 transition-colors"
|
||||
class="w-16 h-16 rounded-lg bg-ghost-800 border border-ghost-700 flex items-center justify-center flex-shrink-0 group-hover:border-purple-500/50 transition-colors"
|
||||
>
|
||||
<img
|
||||
v-if="member.avatar"
|
||||
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
|
||||
:alt="member.name"
|
||||
class="w-8 h-8 object-contain"
|
||||
class="w-12 h-12 object-contain"
|
||||
/>
|
||||
<span v-else class="text-xl text-stone-600">👻</span>
|
||||
<span v-else class="text-2xl text-ghost-600">👻</span>
|
||||
</div>
|
||||
|
||||
<!-- Name and Info -->
|
||||
<!-- Name and Meta Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-baseline gap-2 flex-wrap">
|
||||
<div class="flex items-baseline gap-2 flex-wrap mb-2">
|
||||
<NuxtLink
|
||||
:to="`/updates/user/${member._id}`"
|
||||
class="font-semibold text-stone-100 hover:text-purple-300 transition-colors"
|
||||
class="font-semibold text-lg text-ghost-100 hover:text-purple-300 transition-colors"
|
||||
>
|
||||
{{ member.name }}
|
||||
</NuxtLink>
|
||||
<span v-if="member.pronouns" class="text-sm text-stone-400">
|
||||
<span v-if="member.pronouns" class="text-sm text-ghost-400">
|
||||
{{ member.pronouns }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 flex-wrap mb-2">
|
||||
<span
|
||||
class="px-2 py-0.5 bg-purple-500/20 text-purple-300 rounded text-xs border border-purple-500/30"
|
||||
>
|
||||
{{ circleLabels[member.circle] }}
|
||||
</span>
|
||||
<span v-if="member.studio" class="text-sm text-stone-400">
|
||||
<span v-if="member.studio" class="text-sm text-ghost-400">
|
||||
{{ member.studio }}
|
||||
</span>
|
||||
<span v-if="member.location" class="text-sm text-stone-500">
|
||||
{{ member.location }}
|
||||
<span v-if="member.location" class="text-sm text-ghost-500">
|
||||
📍 {{ member.location }}
|
||||
</span>
|
||||
<span v-if="member.timeZone" class="text-sm text-ghost-500">
|
||||
🕐 {{ member.timeZone }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div
|
||||
v-if="member.socialLinks && hasSocialLinks(member.socialLinks)"
|
||||
class="flex gap-3 flex-shrink-0"
|
||||
v-if="
|
||||
member.socialLinks && hasSocialLinks(member.socialLinks)
|
||||
"
|
||||
class="flex gap-3"
|
||||
>
|
||||
<a
|
||||
v-if="member.socialLinks.mastodon"
|
||||
:href="member.socialLinks.mastodon"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-stone-400 hover:text-purple-400 transition-colors"
|
||||
class="text-ghost-400 hover:text-purple-400 transition-colors"
|
||||
title="Mastodon"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M23.193 7.879c0-5.206-3.411-6.732-3.411-6.732C18.062.357 15.108.025 12.041 0h-.076c-3.068.025-6.02.357-7.74 1.147 0 0-3.411 1.526-3.411 6.732 0 1.192-.023 2.618.015 4.129.124 5.092.934 10.109 5.641 11.355 2.17.574 4.034.695 5.535.612 2.722-.15 4.25-.972 4.25-.972l-.09-1.975s-1.945.613-4.129.539c-2.165-.074-4.449-.233-4.799-2.891a5.499 5.499 0 0 1-.048-.745s2.125.52 4.817.643c1.646.075 3.19-.097 4.758-.283 3.007-.359 5.625-2.212 5.954-3.905.517-2.665.475-6.507.475-6.507zm-4.024 6.709h-2.497V8.469c0-1.29-.543-1.944-1.628-1.944-1.2 0-1.802.776-1.802 2.312v3.349h-2.483v-3.35c0-1.536-.602-2.312-1.802-2.312-1.085 0-1.628.655-1.628 1.944v6.119H4.832V8.284c0-1.289.328-2.313.987-3.07.68-.758 1.569-1.146 2.674-1.146 1.278 0 2.246.491 2.886 1.474L12 6.585l.622-1.043c.64-.983 1.608-1.474 2.886-1.474 1.104 0 1.994.388 2.674 1.146.658.757.986 1.781.986 3.07v6.304z"
|
||||
/>
|
||||
|
|
@ -189,10 +265,14 @@
|
|||
:href="member.socialLinks.linkedin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-stone-400 hover:text-purple-400 transition-colors"
|
||||
class="text-ghost-400 hover:text-purple-400 transition-colors"
|
||||
title="LinkedIn"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<svg
|
||||
class="w-5 h-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"
|
||||
/>
|
||||
|
|
@ -203,7 +283,7 @@
|
|||
:href="member.socialLinks.website"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-stone-400 hover:text-purple-400 transition-colors"
|
||||
class="text-ghost-400 hover:text-purple-400 transition-colors"
|
||||
title="Website"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -225,7 +305,7 @@
|
|||
:href="member.socialLinks.other"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-stone-400 hover:text-purple-400 transition-colors"
|
||||
class="text-ghost-400 hover:text-purple-400 transition-colors"
|
||||
title="Other link"
|
||||
>
|
||||
<svg
|
||||
|
|
@ -245,6 +325,133 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bio -->
|
||||
<div v-if="member.bio" class="mb-4">
|
||||
<p class="text-ghost-300 text-sm leading-relaxed">
|
||||
{{ member.bio }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Peer Support Section -->
|
||||
<div
|
||||
v-if="member.peerSupport?.enabled"
|
||||
class="mb-4 p-4 bg-purple-500/10 border border-purple-500/30 rounded-lg"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-purple-300 font-medium text-sm">
|
||||
💜 Offering Peer Support
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Topics -->
|
||||
<div
|
||||
v-if="
|
||||
member.peerSupport.topics &&
|
||||
member.peerSupport.topics.length > 0
|
||||
"
|
||||
class="mb-2"
|
||||
>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<span
|
||||
v-for="topic in member.peerSupport.topics"
|
||||
:key="topic"
|
||||
class="px-2 py-0.5 bg-purple-500/20 text-purple-200 rounded text-xs border border-purple-500/40"
|
||||
>
|
||||
{{ topic }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Personal Message -->
|
||||
<div
|
||||
v-if="member.peerSupport.personalMessage"
|
||||
class="text-sm text-ghost-300 italic mb-2"
|
||||
>
|
||||
"{{ member.peerSupport.personalMessage }}"
|
||||
</div>
|
||||
|
||||
<!-- Availability -->
|
||||
<div
|
||||
v-if="member.peerSupport.availability"
|
||||
class="text-xs text-ghost-400 mb-2"
|
||||
>
|
||||
Availability: {{ member.peerSupport.availability }}
|
||||
</div>
|
||||
|
||||
<!-- Contact Button -->
|
||||
<a
|
||||
v-if="member.peerSupport.slackUsername"
|
||||
:href="`slack://user?team=T03A96LV4&id=${member.slackUserId}`"
|
||||
@click.prevent="openSlackDM(member)"
|
||||
class="inline-block px-3 py-1.5 bg-purple-500/20 text-purple-300 rounded border border-purple-500/30 hover:bg-purple-500/30 transition-colors text-sm font-medium cursor-pointer"
|
||||
>
|
||||
Message {{ member.peerSupport.slackUsername }} on Slack
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Offering and Looking For -->
|
||||
<div
|
||||
v-if="member.offering || member.lookingFor"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<!-- Offering -->
|
||||
<div v-if="member.offering" class="space-y-2">
|
||||
<h4 class="text-xs font-semibold text-purple-400 uppercase">
|
||||
Offering
|
||||
</h4>
|
||||
<p
|
||||
v-if="member.offering.description"
|
||||
class="text-ghost-300 text-sm"
|
||||
>
|
||||
{{ member.offering.description }}
|
||||
</p>
|
||||
<div
|
||||
v-if="
|
||||
member.offering.tags && member.offering.tags.length > 0
|
||||
"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<span
|
||||
v-for="tag in member.offering.tags"
|
||||
:key="tag"
|
||||
class="px-2 py-0.5 bg-green-500/20 text-green-300 rounded text-xs border border-green-500/30"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Looking For -->
|
||||
<div v-if="member.lookingFor" class="space-y-2">
|
||||
<h4 class="text-xs font-semibold text-purple-400 uppercase">
|
||||
Looking For
|
||||
</h4>
|
||||
<p
|
||||
v-if="member.lookingFor.description"
|
||||
class="text-ghost-300 text-sm"
|
||||
>
|
||||
{{ member.lookingFor.description }}
|
||||
</p>
|
||||
<div
|
||||
v-if="
|
||||
member.lookingFor.tags &&
|
||||
member.lookingFor.tags.length > 0
|
||||
"
|
||||
class="flex flex-wrap gap-1"
|
||||
>
|
||||
<span
|
||||
v-for="tag in member.lookingFor.tags"
|
||||
:key="tag"
|
||||
class="px-2 py-0.5 bg-blue-500/20 text-blue-300 rounded text-xs border border-blue-500/30"
|
||||
>
|
||||
{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
|
|
@ -254,7 +461,7 @@
|
|||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
class="text-stone-600"
|
||||
class="text-ghost-600"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
|
|
@ -264,10 +471,10 @@
|
|||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-stone-300 mb-2">
|
||||
<h3 class="text-lg font-medium text-ghost-300 mb-2">
|
||||
No members found
|
||||
</h3>
|
||||
<p class="text-stone-400 mb-6">
|
||||
<p class="text-ghost-400 mb-6">
|
||||
Try adjusting your search or filters
|
||||
</p>
|
||||
<UButton variant="outline" @click="clearAllFilters">
|
||||
|
|
@ -300,15 +507,19 @@ const { isAuthenticated } = useAuth();
|
|||
const members = ref([]);
|
||||
const totalCount = ref(0);
|
||||
const availableSkills = ref([]);
|
||||
const loading = ref(false);
|
||||
const availableTopics = ref([]);
|
||||
const loading = ref(true); // Start with loading true
|
||||
const searchQuery = ref("");
|
||||
const selectedCircle = ref("");
|
||||
const selectedCircle = ref("all");
|
||||
const peerSupportFilter = ref("all");
|
||||
const selectedSkills = ref([]);
|
||||
const selectedTopics = ref([]);
|
||||
const showAllSkills = ref(false);
|
||||
const showAllTopics = ref(false);
|
||||
|
||||
// Circle options
|
||||
const circleOptions = [
|
||||
{ label: "All Circles", value: "" },
|
||||
{ label: "All Circles", value: "all" },
|
||||
{ label: "Community", value: "community" },
|
||||
{ label: "Founder", value: "founder" },
|
||||
{ label: "Practitioner", value: "practitioner" },
|
||||
|
|
@ -320,6 +531,12 @@ const circleLabels = {
|
|||
practitioner: "Practitioner",
|
||||
};
|
||||
|
||||
// Peer support filter options
|
||||
const peerSupportOptions = [
|
||||
{ label: "All Members", value: "all" },
|
||||
{ label: "Offering Peer Support", value: "true" },
|
||||
];
|
||||
|
||||
// Helper to check if member has social links
|
||||
const hasSocialLinks = (links) => {
|
||||
if (!links) return false;
|
||||
|
|
@ -333,17 +550,27 @@ const loadMembers = async () => {
|
|||
try {
|
||||
const params = {};
|
||||
if (searchQuery.value) params.search = searchQuery.value;
|
||||
if (selectedCircle.value) params.circle = selectedCircle.value;
|
||||
if (selectedCircle.value && selectedCircle.value !== "all")
|
||||
params.circle = selectedCircle.value;
|
||||
if (peerSupportFilter.value && peerSupportFilter.value !== "all")
|
||||
params.peerSupport = peerSupportFilter.value;
|
||||
if (selectedSkills.value.length > 0)
|
||||
params.skills = selectedSkills.value.join(",");
|
||||
if (selectedTopics.value.length > 0)
|
||||
params.topics = selectedTopics.value.join(",");
|
||||
|
||||
const data = await $fetch("/api/members/directory", { params });
|
||||
|
||||
members.value = data.members;
|
||||
totalCount.value = data.totalCount;
|
||||
availableSkills.value = data.filters.availableSkills;
|
||||
members.value = data.members || [];
|
||||
totalCount.value = data.totalCount || 0;
|
||||
availableSkills.value = data.filters?.availableSkills || [];
|
||||
availableTopics.value = data.filters?.availableTopics || [];
|
||||
} catch (error) {
|
||||
console.error("Failed to load members:", error);
|
||||
members.value = [];
|
||||
totalCount.value = 0;
|
||||
availableSkills.value = [];
|
||||
availableTopics.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
|
|
@ -369,19 +596,53 @@ const toggleSkill = (skill) => {
|
|||
loadMembers();
|
||||
};
|
||||
|
||||
// Toggle topic filter
|
||||
const toggleTopic = (topic) => {
|
||||
const index = selectedTopics.value.indexOf(topic);
|
||||
if (index > -1) {
|
||||
selectedTopics.value.splice(index, 1);
|
||||
} else {
|
||||
selectedTopics.value.push(topic);
|
||||
}
|
||||
loadMembers();
|
||||
};
|
||||
|
||||
// Clear filters
|
||||
const clearCircleFilter = () => {
|
||||
selectedCircle.value = "";
|
||||
loadMembers();
|
||||
selectedCircle.value = "all";
|
||||
};
|
||||
|
||||
const clearPeerSupportFilter = () => {
|
||||
peerSupportFilter.value = "all";
|
||||
};
|
||||
|
||||
const clearAllFilters = () => {
|
||||
searchQuery.value = "";
|
||||
selectedCircle.value = "";
|
||||
selectedCircle.value = "all";
|
||||
peerSupportFilter.value = "all";
|
||||
selectedSkills.value = [];
|
||||
selectedTopics.value = [];
|
||||
loadMembers();
|
||||
};
|
||||
|
||||
// Slack DM functionality
|
||||
const openSlackDM = async (member) => {
|
||||
const username = member.peerSupport?.slackUsername || member.name;
|
||||
|
||||
// Copy username to clipboard
|
||||
try {
|
||||
await navigator.clipboard.writeText(username);
|
||||
} catch (err) {
|
||||
console.log("Could not copy to clipboard:", err);
|
||||
}
|
||||
|
||||
// Show alert and open Slack
|
||||
alert(
|
||||
`Opening Slack...\n\nSearch for: ${username}\n\n(Username copied to clipboard)`,
|
||||
);
|
||||
window.open("https://gammaspace.slack.com", "_blank");
|
||||
};
|
||||
|
||||
// Load on mount
|
||||
onMounted(() => {
|
||||
loadMembers();
|
||||
|
|
|
|||
12
app/pages/peer-support.vue
Normal file
12
app/pages/peer-support.vue
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<template>
|
||||
<div></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Redirect to members directory with peer support filter
|
||||
definePageMeta({
|
||||
middleware: defineNuxtRouteMiddleware(() => {
|
||||
return navigateTo("/members?peerSupport=true");
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,135 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Edit Update"
|
||||
subtitle="Make changes to your update"
|
||||
theme="stone"
|
||||
size="medium"
|
||||
/>
|
||||
|
||||
<section class="py-12 px-4">
|
||||
<UContainer class="px-4">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex justify-center items-center py-20"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-stone-400">Loading update...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<div v-else-if="update" class="max-w-3xl">
|
||||
<UpdateForm
|
||||
:initial-data="update"
|
||||
:submitting="submitting"
|
||||
:error="error"
|
||||
submit-label="Update"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div
|
||||
v-if="success"
|
||||
class="mt-6 bg-green-500/10 border border-green-500/30 rounded-lg p-4"
|
||||
>
|
||||
<p class="text-green-300">✓ Update saved successfully!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Not Found -->
|
||||
<div v-else class="text-center py-20">
|
||||
<p class="text-stone-400 mb-4">Update not found</p>
|
||||
<UButton to="/updates" variant="outline" color="neutral">
|
||||
Back to Updates
|
||||
</UButton>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const route = useRoute();
|
||||
const { isAuthenticated, checkMemberStatus, memberData } = useAuth();
|
||||
|
||||
const update = ref(null);
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref(null);
|
||||
const success = ref(false);
|
||||
|
||||
// Load update
|
||||
const loadUpdate = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await $fetch(`/api/updates/${route.params.id}`);
|
||||
|
||||
// Check if user is the author
|
||||
if (memberData.value && data.author._id !== memberData.value.id) {
|
||||
error.value = "You can only edit your own updates";
|
||||
update.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
update.value = data;
|
||||
} catch (err) {
|
||||
console.error("Failed to load update:", err);
|
||||
error.value = err.data?.statusMessage || "Failed to load update";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Check authentication
|
||||
onMounted(async () => {
|
||||
if (!isAuthenticated.value) {
|
||||
const authenticated = await checkMemberStatus();
|
||||
if (!authenticated) {
|
||||
await navigateTo("/login");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await loadUpdate();
|
||||
});
|
||||
|
||||
const handleSubmit = async (formData) => {
|
||||
submitting.value = true;
|
||||
error.value = null;
|
||||
success.value = false;
|
||||
|
||||
try {
|
||||
await $fetch(`/api/updates/${route.params.id}`, {
|
||||
method: "PATCH",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
|
||||
// Redirect to the update after a short delay
|
||||
setTimeout(() => {
|
||||
navigateTo(`/updates/${route.params.id}`);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error("Failed to update:", err);
|
||||
error.value =
|
||||
err.data?.statusMessage || "Failed to save update. Please try again.";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigateTo(`/updates/${route.params.id}`);
|
||||
};
|
||||
|
||||
useHead({
|
||||
title: "Edit Update - Ghost Guild",
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,153 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Update"
|
||||
subtitle="Member update"
|
||||
theme="stone"
|
||||
size="medium"
|
||||
/>
|
||||
|
||||
<section class="py-12 px-4">
|
||||
<UContainer class="px-4">
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="flex justify-center items-center py-20">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-stone-400">Loading update...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Update Content -->
|
||||
<div v-else-if="update" class="max-w-3xl">
|
||||
<UpdateCard
|
||||
:update="update"
|
||||
:show-preview="false"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
|
||||
<!-- Comments Placeholder -->
|
||||
<div
|
||||
class="mt-8 p-8 border border-stone-700 rounded-lg bg-stone-800/30"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-stone-200 mb-4">Comments</h3>
|
||||
<p class="text-stone-400 text-center py-8">Comments coming soon</p>
|
||||
</div>
|
||||
|
||||
<!-- Back Button -->
|
||||
<div class="mt-6">
|
||||
<UButton
|
||||
to="/updates"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
icon="i-lucide-arrow-left"
|
||||
>
|
||||
Back to Updates
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="text-center py-20">
|
||||
<p class="text-stone-400 mb-4">{{ error }}</p>
|
||||
<UButton to="/updates" variant="outline" color="neutral">
|
||||
Back to Updates
|
||||
</UButton>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<UModal
|
||||
v-model:open="showDeleteModal"
|
||||
title="Delete Update?"
|
||||
description="Are you sure you want to delete this update? This action cannot be undone."
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="showDeleteModal = false"
|
||||
>
|
||||
Cancel
|
||||
</UButton>
|
||||
<UButton color="red" :loading="deleting" @click="confirmDelete">
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const route = useRoute();
|
||||
|
||||
const update = ref(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const showDeleteModal = ref(false);
|
||||
const deleting = ref(false);
|
||||
|
||||
// Load update
|
||||
const loadUpdate = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const data = await $fetch(`/api/updates/${route.params.id}`);
|
||||
update.value = data;
|
||||
console.log("✅ Update loaded successfully:", data);
|
||||
} catch (err) {
|
||||
console.error("❌ Failed to load update:", err);
|
||||
console.error("Error details:", {
|
||||
status: err.statusCode,
|
||||
message: err.data?.statusMessage,
|
||||
data: err.data,
|
||||
});
|
||||
error.value =
|
||||
err.data?.statusMessage || err.statusMessage || "Update not found";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadUpdate();
|
||||
});
|
||||
|
||||
const handleEdit = () => {
|
||||
navigateTo(`/updates/${route.params.id}/edit`);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
showDeleteModal.value = true;
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
deleting.value = true;
|
||||
try {
|
||||
await $fetch(`/api/updates/${route.params.id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
// Redirect to updates feed
|
||||
await navigateTo("/updates");
|
||||
} catch (err) {
|
||||
console.error("Failed to delete update:", err);
|
||||
alert("Failed to delete update. Please try again.");
|
||||
deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
update.value
|
||||
? `Update by ${update.value.author?.name} - Ghost Guild`
|
||||
: "Update - Ghost Guild",
|
||||
),
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Community Updates"
|
||||
subtitle="Share and discover what members are working on, learning, and thinking about"
|
||||
theme="stone"
|
||||
size="medium"
|
||||
/>
|
||||
|
||||
<section class="py-12 px-4">
|
||||
<UContainer class="px-4">
|
||||
<!-- New Update Button -->
|
||||
<div v-if="isAuthenticated" class="mb-8 flex justify-end">
|
||||
<UButton to="/updates/new" icon="i-lucide-plus"> New Update </UButton>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="pending && !updates.length"
|
||||
class="flex justify-center items-center py-20"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-stone-400">Loading updates...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Updates Feed -->
|
||||
<div v-else-if="updates.length" class="space-y-6">
|
||||
<UpdateCard
|
||||
v-for="update in updates"
|
||||
:key="update._id"
|
||||
:update="update"
|
||||
:show-preview="true"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
|
||||
<!-- Load More -->
|
||||
<div v-if="hasMore" class="flex justify-center pt-4">
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
:loading="loadingMore"
|
||||
@click="loadMore"
|
||||
>
|
||||
Load More
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-20">
|
||||
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
class="text-stone-600"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-stone-300 mb-2">
|
||||
No updates yet
|
||||
</h3>
|
||||
<p class="text-stone-400 mb-6">
|
||||
Be the first to share an update with the community!
|
||||
</p>
|
||||
<UButton v-if="isAuthenticated" to="/updates/new">
|
||||
Post Your First Update
|
||||
</UButton>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<UModal
|
||||
v-model:open="showDeleteModal"
|
||||
title="Delete Update?"
|
||||
description="Are you sure you want to delete this update? This action cannot be undone."
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="showDeleteModal = false"
|
||||
>
|
||||
Cancel
|
||||
</UButton>
|
||||
<UButton color="red" :loading="deleting" @click="confirmDelete">
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const updates = ref([]);
|
||||
const pending = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const hasMore = ref(false);
|
||||
const total = ref(0);
|
||||
|
||||
const showDeleteModal = ref(false);
|
||||
const updateToDelete = ref(null);
|
||||
const deleting = ref(false);
|
||||
|
||||
// Load initial updates
|
||||
const loadUpdates = async () => {
|
||||
pending.value = true;
|
||||
try {
|
||||
const response = await $fetch("/api/updates", {
|
||||
params: { limit: 20, skip: 0 },
|
||||
});
|
||||
updates.value = response.updates;
|
||||
total.value = response.total;
|
||||
hasMore.value = response.hasMore;
|
||||
} catch (error) {
|
||||
console.error("Failed to load updates:", error);
|
||||
} finally {
|
||||
pending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Load more updates
|
||||
const loadMore = async () => {
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
const response = await $fetch("/api/updates", {
|
||||
params: { limit: 20, skip: updates.value.length },
|
||||
});
|
||||
updates.value.push(...response.updates);
|
||||
hasMore.value = response.hasMore;
|
||||
} catch (error) {
|
||||
console.error("Failed to load more updates:", error);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit
|
||||
const handleEdit = (update) => {
|
||||
navigateTo(`/updates/${update._id}/edit`);
|
||||
};
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = (update) => {
|
||||
updateToDelete.value = update;
|
||||
showDeleteModal.value = true;
|
||||
};
|
||||
|
||||
// Confirm delete
|
||||
const confirmDelete = async () => {
|
||||
if (!updateToDelete.value) return;
|
||||
|
||||
deleting.value = true;
|
||||
try {
|
||||
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
// Remove from list
|
||||
updates.value = updates.value.filter(
|
||||
(u) => u._id !== updateToDelete.value._id,
|
||||
);
|
||||
total.value--;
|
||||
|
||||
showDeleteModal.value = false;
|
||||
updateToDelete.value = null;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete update:", error);
|
||||
alert("Failed to delete update. Please try again.");
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadUpdates();
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: "Community Updates - Ghost Guild",
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
title="New Update"
|
||||
subtitle="Share what you're working on, learning, or thinking about"
|
||||
theme="stone"
|
||||
size="medium"
|
||||
/>
|
||||
|
||||
<section class="py-12 px-4">
|
||||
<UContainer class="px-4">
|
||||
<div class="max-w-3xl">
|
||||
<UpdateForm
|
||||
:submitting="submitting"
|
||||
:error="error"
|
||||
submit-label="Post Update"
|
||||
@submit="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
/>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div
|
||||
v-if="success"
|
||||
class="mt-6 bg-green-500/10 border border-green-500/30 rounded-lg p-4"
|
||||
>
|
||||
<p class="text-green-300">✓ Update posted successfully!</p>
|
||||
</div>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const { isAuthenticated, checkMemberStatus } = useAuth();
|
||||
|
||||
const submitting = ref(false);
|
||||
const error = ref(null);
|
||||
const success = ref(false);
|
||||
|
||||
// Check authentication
|
||||
onMounted(async () => {
|
||||
if (!isAuthenticated.value) {
|
||||
const authenticated = await checkMemberStatus();
|
||||
if (!authenticated) {
|
||||
await navigateTo("/login");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async (formData) => {
|
||||
submitting.value = true;
|
||||
error.value = null;
|
||||
success.value = false;
|
||||
|
||||
try {
|
||||
const update = await $fetch("/api/updates", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
success.value = true;
|
||||
|
||||
// Redirect to the update after a short delay
|
||||
setTimeout(() => {
|
||||
navigateTo(`/updates/${update._id}`);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error("Failed to create update:", err);
|
||||
error.value =
|
||||
err.data?.statusMessage || "Failed to post update. Please try again.";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
navigateTo("/updates");
|
||||
};
|
||||
|
||||
useHead({
|
||||
title: "New Update - Ghost Guild",
|
||||
});
|
||||
</script>
|
||||
|
|
@ -1,193 +0,0 @@
|
|||
<template>
|
||||
<div>
|
||||
<PageHeader
|
||||
:title="user?.name ? `${user.name}'s Updates` : 'User Updates'"
|
||||
:subtitle="user?.name ? `All updates from ${user.name}` : 'Loading...'"
|
||||
theme="stone"
|
||||
size="medium"
|
||||
/>
|
||||
|
||||
<section class="py-12 px-4">
|
||||
<UContainer class="px-4">
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
v-if="pending && !updates.length"
|
||||
class="flex justify-center items-center py-20"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-stone-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
|
||||
></div>
|
||||
<p class="text-stone-400">Loading updates...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Updates Feed -->
|
||||
<div v-else-if="updates.length" class="space-y-6">
|
||||
<UpdateCard
|
||||
v-for="update in updates"
|
||||
:key="update._id"
|
||||
:update="update"
|
||||
:show-preview="true"
|
||||
@edit="handleEdit"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
|
||||
<!-- Load More -->
|
||||
<div v-if="hasMore" class="flex justify-center pt-4">
|
||||
<UButton
|
||||
variant="outline"
|
||||
color="neutral"
|
||||
:loading="loadingMore"
|
||||
@click="loadMore"
|
||||
>
|
||||
Load More
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-else class="text-center py-20">
|
||||
<div class="w-16 h-16 mx-auto mb-4 opacity-50">
|
||||
<svg
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
class="text-stone-600"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-stone-300 mb-2">
|
||||
No updates yet
|
||||
</h3>
|
||||
<p class="text-stone-400">
|
||||
{{ user?.name || "This user" }} hasn't posted any updates.
|
||||
</p>
|
||||
</div>
|
||||
</UContainer>
|
||||
</section>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<UModal
|
||||
v-model:open="showDeleteModal"
|
||||
title="Delete Update?"
|
||||
description="Are you sure you want to delete this update? This action cannot be undone."
|
||||
>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
@click="showDeleteModal = false"
|
||||
>
|
||||
Cancel
|
||||
</UButton>
|
||||
<UButton color="red" :loading="deleting" @click="confirmDelete">
|
||||
Delete
|
||||
</UButton>
|
||||
</div>
|
||||
</template>
|
||||
</UModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const route = useRoute();
|
||||
const userId = computed(() => route.params.id);
|
||||
|
||||
const updates = ref([]);
|
||||
const user = ref(null);
|
||||
const pending = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const hasMore = ref(false);
|
||||
const total = ref(0);
|
||||
|
||||
const showDeleteModal = ref(false);
|
||||
const updateToDelete = ref(null);
|
||||
const deleting = ref(false);
|
||||
|
||||
// Load user updates
|
||||
const loadUpdates = async () => {
|
||||
pending.value = true;
|
||||
try {
|
||||
const response = await $fetch(`/api/updates/user/${userId.value}`, {
|
||||
params: { limit: 20, skip: 0 },
|
||||
});
|
||||
updates.value = response.updates;
|
||||
user.value = response.user;
|
||||
total.value = response.total;
|
||||
hasMore.value = response.hasMore;
|
||||
} catch (error) {
|
||||
console.error("Failed to load updates:", error);
|
||||
} finally {
|
||||
pending.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Load more updates
|
||||
const loadMore = async () => {
|
||||
loadingMore.value = true;
|
||||
try {
|
||||
const response = await $fetch(`/api/updates/user/${userId.value}`, {
|
||||
params: { limit: 20, skip: updates.value.length },
|
||||
});
|
||||
updates.value.push(...response.updates);
|
||||
hasMore.value = response.hasMore;
|
||||
} catch (error) {
|
||||
console.error("Failed to load more updates:", error);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit
|
||||
const handleEdit = (update) => {
|
||||
navigateTo(`/updates/${update._id}/edit`);
|
||||
};
|
||||
|
||||
// Handle delete
|
||||
const handleDelete = (update) => {
|
||||
updateToDelete.value = update;
|
||||
showDeleteModal.value = true;
|
||||
};
|
||||
|
||||
// Confirm delete
|
||||
const confirmDelete = async () => {
|
||||
if (!updateToDelete.value) return;
|
||||
|
||||
deleting.value = true;
|
||||
try {
|
||||
await $fetch(`/api/updates/${updateToDelete.value._id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
// Remove from list
|
||||
updates.value = updates.value.filter(
|
||||
(u) => u._id !== updateToDelete.value._id,
|
||||
);
|
||||
total.value--;
|
||||
|
||||
showDeleteModal.value = false;
|
||||
updateToDelete.value = null;
|
||||
} catch (error) {
|
||||
console.error("Failed to delete update:", error);
|
||||
alert("Failed to delete update. Please try again.");
|
||||
} finally {
|
||||
deleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
loadUpdates();
|
||||
});
|
||||
|
||||
useHead({
|
||||
title: computed(() => user.value?.name ? `${user.value.name}'s Updates - Ghost Guild` : 'User Updates - Ghost Guild'),
|
||||
});
|
||||
</script>
|
||||
163
scripts/migrate-profile-fields.js
Normal file
163
scripts/migrate-profile-fields.js
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
/**
|
||||
* Migration Script: Profile Fields Restructure
|
||||
*
|
||||
* This script migrates member data from the old schema to the new schema:
|
||||
* - Removes `skills` field
|
||||
* - Converts `offering` from String to { text: String, tags: [String] }
|
||||
* - Converts `lookingFor` from String to { text: String, tags: [String] }
|
||||
* - Converts `peerSupport.topics` to `peerSupport.skillTopics` and `peerSupport.supportTopics`
|
||||
* - Removes `privacy.skills`
|
||||
*/
|
||||
|
||||
import mongoose from 'mongoose';
|
||||
import Member from '../server/models/member.js';
|
||||
import { connectDB } from '../server/utils/mongoose.js';
|
||||
|
||||
// Curated list of conversational support topics
|
||||
const CONVERSATIONAL_TOPICS = [
|
||||
'Co-founder relationships',
|
||||
'Burnout prevention',
|
||||
'Impostor syndrome',
|
||||
'Work-life boundaries',
|
||||
'Conflict resolution',
|
||||
'General chat & support',
|
||||
];
|
||||
|
||||
async function migrateProfileFields() {
|
||||
try {
|
||||
await connectDB();
|
||||
console.log('Connected to database');
|
||||
|
||||
// Find all members
|
||||
const members = await Member.find({});
|
||||
console.log(`Found ${members.length} members to migrate`);
|
||||
|
||||
let migratedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const member of members) {
|
||||
let needsUpdate = false;
|
||||
const updates = {};
|
||||
|
||||
// Migrate skills -> offering.tags (if offering doesn't have tags yet)
|
||||
if (member.skills && member.skills.length > 0) {
|
||||
console.log(`\nMember ${member.name} (${member.email}):`);
|
||||
console.log(` - Has skills: ${member.skills.join(', ')}`);
|
||||
|
||||
// If offering is still a string, convert it and add skills as tags
|
||||
if (typeof member.offering === 'string') {
|
||||
updates['offering'] = {
|
||||
text: member.offering || '',
|
||||
tags: member.skills, // Move skills to offering tags
|
||||
};
|
||||
console.log(` - Migrating skills to offering.tags`);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Remove skills field
|
||||
updates.$unset = { skills: 1 };
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Migrate offering from string to object (if not already done)
|
||||
if (typeof member.offering === 'string' && !updates['offering']) {
|
||||
updates['offering'] = {
|
||||
text: member.offering || '',
|
||||
tags: [],
|
||||
};
|
||||
console.log(` - Converting offering to object structure`);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Migrate lookingFor from string to object
|
||||
if (typeof member.lookingFor === 'string') {
|
||||
updates['lookingFor'] = {
|
||||
text: member.lookingFor || '',
|
||||
tags: [],
|
||||
};
|
||||
console.log(` - Converting lookingFor to object structure`);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Migrate peer support topics
|
||||
if (member.peerSupport?.topics && member.peerSupport.topics.length > 0) {
|
||||
const skillTopics = [];
|
||||
const supportTopics = [];
|
||||
|
||||
// Split topics into skill-based and conversational
|
||||
for (const topic of member.peerSupport.topics) {
|
||||
if (CONVERSATIONAL_TOPICS.includes(topic)) {
|
||||
supportTopics.push(topic);
|
||||
} else {
|
||||
skillTopics.push(topic);
|
||||
}
|
||||
}
|
||||
|
||||
updates['peerSupport.skillTopics'] = skillTopics;
|
||||
updates['peerSupport.supportTopics'] = supportTopics;
|
||||
updates['$unset'] = {
|
||||
...(updates['$unset'] || {}),
|
||||
'peerSupport.topics': 1
|
||||
};
|
||||
|
||||
console.log(` - Splitting peer support topics:`);
|
||||
console.log(` Skill topics: ${skillTopics.join(', ') || 'none'}`);
|
||||
console.log(` Support topics: ${supportTopics.join(', ') || 'none'}`);
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Remove privacy.skills if it exists
|
||||
if (member.privacy?.skills) {
|
||||
updates['$unset'] = {
|
||||
...(updates['$unset'] || {}),
|
||||
'privacy.skills': 1
|
||||
};
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Apply updates
|
||||
if (needsUpdate) {
|
||||
const updateOps = { ...updates };
|
||||
const unsetOps = updateOps.$unset;
|
||||
delete updateOps.$unset;
|
||||
|
||||
const finalUpdate = {};
|
||||
if (Object.keys(updateOps).length > 0) {
|
||||
finalUpdate.$set = updateOps;
|
||||
}
|
||||
if (unsetOps && Object.keys(unsetOps).length > 0) {
|
||||
finalUpdate.$unset = unsetOps;
|
||||
}
|
||||
|
||||
await Member.updateOne({ _id: member._id }, finalUpdate);
|
||||
console.log(` ✓ Updated`);
|
||||
migratedCount++;
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n=== Migration Complete ===');
|
||||
console.log(`Total members: ${members.length}`);
|
||||
console.log(`Migrated: ${migratedCount}`);
|
||||
console.log(`Skipped (already migrated): ${skippedCount}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Migration error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
await mongoose.connection.close();
|
||||
console.log('\nDatabase connection closed');
|
||||
}
|
||||
}
|
||||
|
||||
// Run migration
|
||||
migrateProfileFields()
|
||||
.then(() => {
|
||||
console.log('\n✓ Migration script completed successfully');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('\n✗ Migration script failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -41,13 +41,14 @@ export default defineEventHandler(async (event) => {
|
|||
avatar: member.avatar,
|
||||
studio: member.studio,
|
||||
bio: member.bio,
|
||||
skills: member.skills,
|
||||
location: member.location,
|
||||
socialLinks: member.socialLinks,
|
||||
offering: member.offering,
|
||||
lookingFor: member.lookingFor,
|
||||
showInDirectory: member.showInDirectory,
|
||||
privacy: member.privacy,
|
||||
// Peer support
|
||||
peerSupport: member.peerSupport,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("Token verification error:", err);
|
||||
|
|
|
|||
|
|
@ -24,7 +24,9 @@ export default defineEventHandler(async (event) => {
|
|||
const query = getQuery(event);
|
||||
const search = query.search || "";
|
||||
const circle = query.circle || "";
|
||||
const skills = query.skills ? query.skills.split(",") : [];
|
||||
const tags = query.tags ? query.tags.split(",") : [];
|
||||
const peerSupport = query.peerSupport || "";
|
||||
const topics = query.topics ? query.topics.split(",") : [];
|
||||
|
||||
// Build query
|
||||
const dbQuery = {
|
||||
|
|
@ -37,6 +39,11 @@ export default defineEventHandler(async (event) => {
|
|||
dbQuery.circle = circle;
|
||||
}
|
||||
|
||||
// Filter by peer support availability
|
||||
if (peerSupport === "true") {
|
||||
dbQuery["peerSupport.enabled"] = true;
|
||||
}
|
||||
|
||||
// Search by name or bio
|
||||
if (search) {
|
||||
dbQuery.$or = [
|
||||
|
|
@ -45,15 +52,41 @@ export default defineEventHandler(async (event) => {
|
|||
];
|
||||
}
|
||||
|
||||
// Filter by skills
|
||||
if (skills.length > 0) {
|
||||
dbQuery.skills = { $in: skills };
|
||||
// Filter by tags (search in offering.tags or lookingFor.tags)
|
||||
if (tags.length > 0) {
|
||||
dbQuery.$or = [
|
||||
{ "offering.tags": { $in: tags } },
|
||||
{ "lookingFor.tags": { $in: tags } },
|
||||
];
|
||||
// If search is also present, combine with AND
|
||||
if (search) {
|
||||
dbQuery.$and = [
|
||||
{
|
||||
$or: [
|
||||
{ name: { $regex: search, $options: "i" } },
|
||||
{ bio: { $regex: search, $options: "i" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
$or: [
|
||||
{ "offering.tags": { $in: tags } },
|
||||
{ "lookingFor.tags": { $in: tags } },
|
||||
],
|
||||
},
|
||||
];
|
||||
delete dbQuery.$or;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter by peer support topics
|
||||
if (topics.length > 0) {
|
||||
dbQuery["peerSupport.topics"] = { $in: topics };
|
||||
}
|
||||
|
||||
try {
|
||||
const members = await Member.find(dbQuery)
|
||||
.select(
|
||||
"name pronouns timeZone avatar studio bio skills location socialLinks offering lookingFor privacy circle createdAt"
|
||||
"name pronouns timeZone avatar studio bio location socialLinks offering lookingFor privacy circle peerSupport slackUserId createdAt",
|
||||
)
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
|
@ -83,26 +116,42 @@ export default defineEventHandler(async (event) => {
|
|||
if (isVisible("timeZone")) filtered.timeZone = member.timeZone;
|
||||
if (isVisible("studio")) filtered.studio = member.studio;
|
||||
if (isVisible("bio")) filtered.bio = member.bio;
|
||||
if (isVisible("skills")) filtered.skills = member.skills;
|
||||
if (isVisible("location")) filtered.location = member.location;
|
||||
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
|
||||
if (isVisible("offering")) filtered.offering = member.offering;
|
||||
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor;
|
||||
|
||||
// Always show peer support if enabled (it's opt-in, so public by nature)
|
||||
if (member.peerSupport?.enabled) {
|
||||
filtered.peerSupport = member.peerSupport;
|
||||
filtered.slackUserId = member.slackUserId;
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
|
||||
// Get unique skills for filter options
|
||||
const allSkills = members
|
||||
.flatMap((m) => m.skills || [])
|
||||
.filter((skill, index, self) => self.indexOf(skill) === index)
|
||||
// Get unique tags for filter options (from both offering and lookingFor)
|
||||
const allTags = members
|
||||
.flatMap((m) => [
|
||||
...(m.offering?.tags || []),
|
||||
...(m.lookingFor?.tags || []),
|
||||
])
|
||||
.filter((tag, index, self) => self.indexOf(tag) === index)
|
||||
.sort();
|
||||
|
||||
// Get unique peer support topics
|
||||
const allTopics = members
|
||||
.filter((m) => m.peerSupport?.enabled)
|
||||
.flatMap((m) => m.peerSupport?.topics || [])
|
||||
.filter((topic, index, self) => self.indexOf(topic) === index)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
members: filteredMembers,
|
||||
totalCount: filteredMembers.length,
|
||||
filters: {
|
||||
availableSkills: allSkills,
|
||||
availableSkills: allTags,
|
||||
availableTopics: allTopics,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
|
|
|||
120
server/api/members/me/peer-support.patch.js
Normal file
120
server/api/members/me/peer-support.patch.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import Member from "../../../models/member.js";
|
||||
import { connectDB } from "../../../utils/mongoose.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB();
|
||||
|
||||
const token = getCookie(event, "auth-token");
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Not authenticated",
|
||||
});
|
||||
}
|
||||
|
||||
let memberId;
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
memberId = decoded.memberId;
|
||||
} catch (err) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
|
||||
const body = await readBody(event);
|
||||
|
||||
// Build update object for peer support settings
|
||||
const updateData = {
|
||||
"peerSupport.enabled": body.enabled || false,
|
||||
"peerSupport.skillTopics": body.skillTopics || [],
|
||||
"peerSupport.supportTopics": body.supportTopics || [],
|
||||
"peerSupport.availability": body.availability || "",
|
||||
"peerSupport.personalMessage": body.personalMessage || "",
|
||||
"peerSupport.slackUsername": body.slackUsername || "",
|
||||
};
|
||||
|
||||
// If Slack username provided and peer support enabled, try to fetch Slack user ID
|
||||
if (body.enabled && body.slackUsername) {
|
||||
try {
|
||||
console.log(
|
||||
`[Peer Support] Attempting to fetch Slack user ID for: ${body.slackUsername}`,
|
||||
);
|
||||
|
||||
// Dynamically import the Slack service
|
||||
const { getSlackService } = await import("../../../utils/slack.ts");
|
||||
const slackService = getSlackService();
|
||||
|
||||
if (slackService) {
|
||||
console.log(
|
||||
"[Peer Support] Slack service initialized, looking up user...",
|
||||
);
|
||||
const slackUserId = await slackService.findUserIdByUsername(
|
||||
body.slackUsername,
|
||||
);
|
||||
|
||||
if (slackUserId) {
|
||||
updateData["slackUserId"] = slackUserId;
|
||||
console.log(
|
||||
`[Peer Support] ✓ Found Slack user ID for ${body.slackUsername}: ${slackUserId}`,
|
||||
);
|
||||
|
||||
// Now get/create the DM channel
|
||||
console.log("[Peer Support] Opening DM channel...");
|
||||
const dmChannelId = await slackService.openDMChannel(slackUserId);
|
||||
|
||||
if (dmChannelId) {
|
||||
updateData["peerSupport.slackDMChannelId"] = dmChannelId;
|
||||
console.log(`[Peer Support] ✓ Got DM channel ID: ${dmChannelId}`);
|
||||
} else {
|
||||
console.warn("[Peer Support] Could not get DM channel ID");
|
||||
}
|
||||
} else {
|
||||
console.warn(
|
||||
`[Peer Support] Could not find Slack user ID for username: ${body.slackUsername}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
"[Peer Support] Slack service not configured, skipping user ID lookup",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Peer Support] Error fetching Slack user ID:",
|
||||
error.message,
|
||||
);
|
||||
console.error("[Peer Support] Stack trace:", error.stack);
|
||||
// Continue anyway - we'll still save the username
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const member = await Member.findByIdAndUpdate(
|
||||
memberId,
|
||||
{ $set: updateData },
|
||||
{ new: true, runValidators: true },
|
||||
);
|
||||
|
||||
if (!member) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Member not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
peerSupport: member.peerSupport,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Peer support update error:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to update peer support settings",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
|
@ -34,11 +34,8 @@ export default defineEventHandler(async (event) => {
|
|||
"avatar",
|
||||
"studio",
|
||||
"bio",
|
||||
"skills",
|
||||
"location",
|
||||
"socialLinks",
|
||||
"offering",
|
||||
"lookingFor",
|
||||
"showInDirectory",
|
||||
"helcimCustomerId",
|
||||
];
|
||||
|
|
@ -50,7 +47,6 @@ export default defineEventHandler(async (event) => {
|
|||
"avatarPrivacy",
|
||||
"studioPrivacy",
|
||||
"bioPrivacy",
|
||||
"skillsPrivacy",
|
||||
"locationPrivacy",
|
||||
"socialLinksPrivacy",
|
||||
"offeringPrivacy",
|
||||
|
|
@ -66,6 +62,20 @@ export default defineEventHandler(async (event) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Handle offering and lookingFor separately (nested objects)
|
||||
if (body.offering !== undefined) {
|
||||
updateData.offering = {
|
||||
text: body.offering.text || "",
|
||||
tags: body.offering.tags || [],
|
||||
};
|
||||
}
|
||||
if (body.lookingFor !== undefined) {
|
||||
updateData.lookingFor = {
|
||||
text: body.lookingFor.text || "",
|
||||
tags: body.lookingFor.tags || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Handle privacy settings
|
||||
privacyFields.forEach((privacyField) => {
|
||||
if (body[privacyField] !== undefined) {
|
||||
|
|
@ -100,7 +110,6 @@ export default defineEventHandler(async (event) => {
|
|||
avatar: member.avatar,
|
||||
studio: member.studio,
|
||||
bio: member.bio,
|
||||
skills: member.skills,
|
||||
location: member.location,
|
||||
socialLinks: member.socialLinks,
|
||||
offering: member.offering,
|
||||
|
|
|
|||
63
server/api/peer-support.get.js
Normal file
63
server/api/peer-support.get.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import Member from "../models/member.js";
|
||||
import { connectDB } from "../utils/mongoose.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB();
|
||||
|
||||
// Check if user is authenticated (optional for this endpoint)
|
||||
const token = getCookie(event, "auth-token");
|
||||
let isAuthenticated = false;
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
jwt.verify(token, process.env.JWT_SECRET);
|
||||
isAuthenticated = true;
|
||||
} catch (err) {
|
||||
isAuthenticated = false;
|
||||
}
|
||||
}
|
||||
|
||||
const query = getQuery(event);
|
||||
const topic = query.topic;
|
||||
|
||||
// Build query for peer supporters
|
||||
const dbQuery = {
|
||||
"peerSupport.enabled": true,
|
||||
status: "active",
|
||||
};
|
||||
|
||||
// Filter by topic if specified
|
||||
if (topic) {
|
||||
dbQuery["peerSupport.topics"] = topic;
|
||||
}
|
||||
|
||||
try {
|
||||
const supporters = await Member.find(dbQuery)
|
||||
.select(
|
||||
"name avatar circle peerSupport slackUserId createdAt"
|
||||
)
|
||||
.sort({ createdAt: -1 })
|
||||
.lean();
|
||||
|
||||
// Get unique topics for filter options
|
||||
const allTopics = supporters
|
||||
.flatMap((supporter) => supporter.peerSupport?.topics || [])
|
||||
.filter((topic, index, self) => self.indexOf(topic) === index)
|
||||
.sort();
|
||||
|
||||
return {
|
||||
supporters,
|
||||
totalCount: supporters.length,
|
||||
filters: {
|
||||
availableTopics: allTopics,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Peer support fetch error:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to fetch peer supporters",
|
||||
});
|
||||
}
|
||||
});
|
||||
28
server/api/test/peer-support-debug.get.js
Normal file
28
server/api/test/peer-support-debug.get.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import Member from "../../models/member.js";
|
||||
import { connectDB } from "../../utils/mongoose.js";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await connectDB();
|
||||
|
||||
const token = getCookie(event, "auth-token");
|
||||
if (!token) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Not authenticated" });
|
||||
}
|
||||
|
||||
let memberId;
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
memberId = decoded.memberId;
|
||||
} catch (err) {
|
||||
throw createError({ statusCode: 401, statusMessage: "Invalid token" });
|
||||
}
|
||||
|
||||
const member = await Member.findById(memberId).select("name peerSupport slackUserId");
|
||||
|
||||
return {
|
||||
name: member.name,
|
||||
peerSupport: member.peerSupport,
|
||||
slackUserId: member.slackUserId,
|
||||
};
|
||||
});
|
||||
62
server/migrations/fix-offering-lookingfor-structure.js
Normal file
62
server/migrations/fix-offering-lookingfor-structure.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// Migration to fix offering and lookingFor field structure
|
||||
// Run this once to convert string values to object structure
|
||||
import mongoose from "mongoose";
|
||||
import Member from "../models/member.js";
|
||||
import { connectDB } from "../utils/mongoose.js";
|
||||
|
||||
async function migrateOfferingLookingFor() {
|
||||
await connectDB();
|
||||
|
||||
console.log("Starting migration: fixing offering and lookingFor structure...");
|
||||
|
||||
try {
|
||||
// Find all members where offering or lookingFor is a string (not an object)
|
||||
const members = await Member.find({
|
||||
$or: [
|
||||
{ offering: { $type: "string" } },
|
||||
{ lookingFor: { $type: "string" } },
|
||||
],
|
||||
});
|
||||
|
||||
console.log(`Found ${members.length} members to migrate`);
|
||||
|
||||
for (const member of members) {
|
||||
const updates = {};
|
||||
|
||||
// Convert offering if it's a string
|
||||
if (typeof member.offering === "string") {
|
||||
updates.offering = {
|
||||
text: member.offering,
|
||||
tags: [],
|
||||
};
|
||||
console.log(
|
||||
`Converting offering for member ${member._id}: "${member.offering}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert lookingFor if it's a string
|
||||
if (typeof member.lookingFor === "string") {
|
||||
updates.lookingFor = {
|
||||
text: member.lookingFor,
|
||||
tags: [],
|
||||
};
|
||||
console.log(
|
||||
`Converting lookingFor for member ${member._id}: "${member.lookingFor}"`,
|
||||
);
|
||||
}
|
||||
|
||||
// Update the member
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await Member.findByIdAndUpdate(member._id, { $set: updates });
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Migration completed successfully!");
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error("Migration failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
migrateOfferingLookingFor();
|
||||
|
|
@ -51,7 +51,6 @@ const memberSchema = new mongoose.Schema({
|
|||
avatar: String,
|
||||
studio: String,
|
||||
bio: String,
|
||||
skills: [String],
|
||||
location: String,
|
||||
socialLinks: {
|
||||
mastodon: String,
|
||||
|
|
@ -59,10 +58,27 @@ const memberSchema = new mongoose.Schema({
|
|||
website: String,
|
||||
other: String,
|
||||
},
|
||||
offering: String,
|
||||
lookingFor: String,
|
||||
offering: {
|
||||
text: String,
|
||||
tags: [String],
|
||||
},
|
||||
lookingFor: {
|
||||
text: String,
|
||||
tags: [String],
|
||||
},
|
||||
showInDirectory: { type: Boolean, default: true },
|
||||
|
||||
// Peer support settings
|
||||
peerSupport: {
|
||||
enabled: { type: Boolean, default: false },
|
||||
skillTopics: [String], // Auto-populated from offering.tags, editable
|
||||
supportTopics: [String], // Curated conversational/emotional support topics
|
||||
availability: String,
|
||||
personalMessage: String,
|
||||
slackUsername: String,
|
||||
slackDMChannelId: String, // DM channel ID for direct messaging
|
||||
},
|
||||
|
||||
// Privacy settings for profile fields
|
||||
privacy: {
|
||||
pronouns: {
|
||||
|
|
@ -90,11 +106,6 @@ const memberSchema = new mongoose.Schema({
|
|||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
skills: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
default: "members",
|
||||
},
|
||||
location: {
|
||||
type: String,
|
||||
enum: ["public", "members", "private"],
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export class SlackService {
|
|||
*/
|
||||
async inviteUserToSlack(
|
||||
email: string,
|
||||
realName: string
|
||||
realName: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
userId?: string;
|
||||
|
|
@ -34,7 +34,7 @@ export class SlackService {
|
|||
});
|
||||
|
||||
console.log(
|
||||
`Successfully invited existing user ${email} to vetting channel`
|
||||
`Successfully invited existing user ${email} to vetting channel`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -65,7 +65,7 @@ export class SlackService {
|
|||
|
||||
if (inviteResponse.ok && inviteResponse.user) {
|
||||
console.log(
|
||||
`Successfully invited ${email} to workspace as single-channel guest`
|
||||
`Successfully invited ${email} to workspace as single-channel guest`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
|
|
@ -79,7 +79,7 @@ export class SlackService {
|
|||
console.log(
|
||||
`Admin API not available or failed: ${
|
||||
adminError.data?.error || adminError.message
|
||||
}`
|
||||
}`,
|
||||
);
|
||||
|
||||
// Fall back to manual process
|
||||
|
|
@ -113,6 +113,67 @@ export class SlackService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user ID by username (display name or real name)
|
||||
*/
|
||||
async findUserIdByUsername(username: string): Promise<string | null> {
|
||||
try {
|
||||
const cleanUsername = username.replace("@", "").toLowerCase();
|
||||
|
||||
// List all users and search for matching username
|
||||
const response = await this.client.users.list();
|
||||
|
||||
if (!response.members) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Search for user by name or display_name
|
||||
const user = response.members.find((member: any) => {
|
||||
const name = member.name?.toLowerCase() || "";
|
||||
const realName = member.real_name?.toLowerCase() || "";
|
||||
const displayName = member.profile?.display_name?.toLowerCase() || "";
|
||||
|
||||
return (
|
||||
name === cleanUsername ||
|
||||
displayName === cleanUsername ||
|
||||
realName.includes(cleanUsername)
|
||||
);
|
||||
});
|
||||
|
||||
return user?.id || null;
|
||||
} catch (error) {
|
||||
console.error("Error looking up Slack user by username:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open/get a DM channel with a user and return the channel ID
|
||||
* This creates or opens a DM conversation and returns the channel ID (starts with D)
|
||||
*/
|
||||
async openDMChannel(userId: string): Promise<string | null> {
|
||||
try {
|
||||
const response = await this.client.conversations.open({
|
||||
users: userId,
|
||||
});
|
||||
|
||||
if (response.ok && response.channel?.id) {
|
||||
console.log(
|
||||
`Opened DM channel for user ${userId}: ${response.channel.id}`,
|
||||
);
|
||||
return response.channel.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
console.error(
|
||||
"Error opening DM channel:",
|
||||
error.data?.error || error.message,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification to the vetting channel about a new member
|
||||
*/
|
||||
|
|
@ -121,7 +182,7 @@ export class SlackService {
|
|||
memberEmail: string,
|
||||
circle: string,
|
||||
contributionTier: string,
|
||||
invitationStatus: string = "manual_invitation_required"
|
||||
invitationStatus: string = "manual_invitation_required",
|
||||
): Promise<void> {
|
||||
try {
|
||||
let statusMessage = "";
|
||||
|
|
@ -224,7 +285,7 @@ export function getSlackService(): SlackService | null {
|
|||
|
||||
if (!config.slackBotToken || !config.slackVettingChannelId) {
|
||||
console.warn(
|
||||
"Slack integration not configured - missing bot token or channel ID"
|
||||
"Slack integration not configured - missing bot token or channel ID",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue