Compare commits

...

3 commits

39 changed files with 2267 additions and 1960 deletions

View file

@ -6,8 +6,13 @@ export default defineAppConfig({
}, },
formField: { formField: {
slots: { 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'
},
}); });

View file

@ -2,14 +2,52 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@nuxt/ui"; @import "@nuxt/ui";
@theme static { @theme {
/* Font families */ /* Font families */
--font-sans: "Inter", sans-serif; --font-sans: "Inter", sans-serif;
--font-body: "Inter", sans-serif; --font-body: "Inter", sans-serif;
--font-mono: "Ubuntu Mono", monospace; --font-mono: "Ubuntu Mono", monospace;
--font-display: "NB Television Pro", 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-50: #f0f0f0;
--color-ghost-100: #d0d0d0; --color-ghost-100: #d0d0d0;
--color-ghost-200: #b0b0b0; --color-ghost-200: #b0b0b0;
@ -20,8 +58,8 @@
--color-ghost-700: #2a2a2a; --color-ghost-700: #2a2a2a;
--color-ghost-800: #1a1a1a; --color-ghost-800: #1a1a1a;
--color-ghost-900: #0a0a0a; --color-ghost-900: #0a0a0a;
/* Subtle accent - barely visible blue-gray */ /* Subtle accent - barely visible blue-gray (dark mode) */
--color-whisper-50: #d4dae6; --color-whisper-50: #d4dae6;
--color-whisper-100: #a8b3c7; --color-whisper-100: #a8b3c7;
--color-whisper-200: #8491a8; --color-whisper-200: #8491a8;
@ -33,7 +71,7 @@
--color-whisper-800: #1a1f2e; --color-whisper-800: #1a1f2e;
--color-whisper-900: #0f1419; --color-whisper-900: #0f1419;
/* Sparkle accent */ /* Sparkle accent (dark mode) */
--color-sparkle-50: #fafafa; --color-sparkle-50: #fafafa;
--color-sparkle-100: #f0f0f0; --color-sparkle-100: #f0f0f0;
--color-sparkle-200: #e8e8e8; --color-sparkle-200: #e8e8e8;
@ -46,33 +84,79 @@
--color-sparkle-900: #202020; --color-sparkle-900: #202020;
} }
/* Global ethereal background */ /* Global ethereal background - light mode */
:root { :root {
--ethereal-bg: radial-gradient(circle at 20% 80%, rgba(232, 232, 232, 0.03) 0%, transparent 50%), --ethereal-bg:
radial-gradient(circle at 80% 20%, rgba(232, 232, 232, 0.02) 0%, transparent 50%), radial-gradient(
radial-gradient(circle at 40% 40%, rgba(232, 232, 232, 0.01) 0%, transparent 50%); circle at 20% 80%,
rgba(40, 40, 40, 0.03) 0%,
--halftone-pattern: radial-gradient(circle, rgba(255,255,255,0.1) 1px, transparent 1px); 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(0, 0, 0, 0.1) 1px,
transparent 1px
);
--halftone-size: 8px 8px; --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 { html {
background: var(--color-ghost-900); @apply text-[--ui-text];
color: var(--color-ghost-200);
} }
body { body {
background: var(--ethereal-bg), var(--color-ghost-900); background: var(--ethereal-bg), #f0f0f0;
background-attachment: fixed; background-attachment: fixed;
} }
.dark body {
background: var(--ethereal-bg), #0a0a0a;
}
/* Halftone texture overlay */ /* Halftone texture overlay */
.halftone-texture { .halftone-texture {
position: relative; position: relative;
} }
.halftone-texture::before { .halftone-texture::before {
content: ''; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
@ -86,14 +170,28 @@ body {
/* Sparkle effects */ /* Sparkle effects */
@keyframes sparkle { @keyframes sparkle {
0%, 100% { opacity: 0.3; transform: scale(0.8); } 0%,
50% { opacity: 1; transform: scale(1.2); } 100% {
opacity: 0.3;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
} }
@keyframes twinkle { @keyframes twinkle {
0%, 100% { opacity: 0.2; } 0%,
25% { opacity: 0.8; } 100% {
75% { opacity: 0.4; } opacity: 0.2;
}
25% {
opacity: 0.8;
}
75% {
opacity: 0.4;
}
} }
.sparkle-field { .sparkle-field {
@ -102,20 +200,50 @@ body {
} }
.sparkle-field::after { .sparkle-field::after {
content: ''; content: "";
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background-image: background-image:
radial-gradient(circle at 10% 20%, var(--color-sparkle-200) 1px, transparent 1px), radial-gradient(
radial-gradient(circle at 90% 80%, var(--color-sparkle-400) 1px, transparent 1px), circle at 10% 20%,
radial-gradient(circle at 30% 70%, var(--color-sparkle-200) 0.5px, transparent 0.5px), var(--color-sparkle-200) 1px,
radial-gradient(circle at 70% 30%, var(--color-sparkle-400) 0.5px, transparent 0.5px), transparent 1px
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); radial-gradient(
background-size: 200px 200px, 300px 300px, 150px 150px, 250px 250px, 180px 180px, 220px 220px; 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; animation: twinkle 4s infinite ease-in-out;
pointer-events: none; pointer-events: none;
opacity: 0.6; opacity: 0.6;
@ -123,7 +251,7 @@ body {
/* Ethereal glow effects */ /* Ethereal glow effects */
.ethereal-glow { .ethereal-glow {
box-shadow: box-shadow:
0 0 20px rgba(232, 232, 232, 0.1), 0 0 20px rgba(232, 232, 232, 0.1),
0 0 40px rgba(232, 232, 232, 0.05), 0 0 40px rgba(232, 232, 232, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.1); inset 0 1px 0 rgba(255, 255, 255, 0.1);
@ -135,12 +263,28 @@ body {
/* Dithered gradients */ /* Dithered gradients */
.dithered-bg { .dithered-bg {
background: background:
linear-gradient(45deg, var(--color-ghost-800) 25%, transparent 25%), linear-gradient(45deg, var(--color-ghost-800) 25%, transparent 25%),
linear-gradient(-45deg, var(--color-ghost-800) 25%, transparent 25%), linear-gradient(-45deg, var(--color-ghost-800) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--color-ghost-700) 75%), linear-gradient(45deg, transparent 75%, var(--color-ghost-700) 75%),
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-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;
}
}

View file

@ -7,7 +7,7 @@
> >
<!-- Left: Copyright and minimal info --> <!-- Left: Copyright and minimal info -->
<div> <div>
<p class="text-stone-500 text-xs mb-2"> <p class="text-ghost-500 text-xs mb-2">
© {{ currentYear }} Ghost Guild © {{ currentYear }} Ghost Guild
</p> </p>
</div> </div>
@ -16,7 +16,7 @@
<div class="flex flex-wrap gap-6 text-xs"> <div class="flex flex-wrap gap-6 text-xs">
<a <a
href="mailto:hello@ghostguild.org" 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 Contact
</a> </a>

View file

@ -1,27 +1,38 @@
<template> <template>
<nav <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 --> <!-- Logo/Brand at top (desktop only) -->
<div class="p-8 border-b border-ghost-800 bg-blue-400"> <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"> <NuxtLink to="/" class="flex flex-col items-center gap-3 group">
<span <span class="text-xl font-bold text-white ethereal-text tracking-wider"
class="text-xl font-bold text-stone-100 ethereal-text tracking-wider"
>Ghost Guild Logo</span >Ghost Guild Logo</span
> >
</NuxtLink> </NuxtLink>
</div> </div>
<!-- Vertical Navigation --> <!-- Vertical Navigation -->
<div class="flex-1 p-8 overflow-y-auto"> <div
<ul class="space-y-6"> :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"> <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 --> <!-- Hover indicator -->
<span <span
class="text-stone-200 hover:text-stone-100 transition-all duration-300 text-lg tracking-wide block py-2 hover:ethereal-text" 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-stone-100 ethereal-text translate-x-2" active-class="text-ghost-100 ethereal-text translate-x-2"
> >
{{ item.label }} {{ item.label }}
</span> </span>
@ -29,12 +40,24 @@
</li> </li>
</ul> </ul>
<!-- Color Mode Switcher -->
<div class="mb-6">
<UColorModeButton size="md" class="w-full" />
</div>
<!-- Auth section --> <!-- 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"> <div v-if="isAuthenticated" class="space-y-4">
<NuxtLink <NuxtLink
to="/member/dashboard" 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">{{ <span class="block text-sm text-whisper-400 mb-1">{{
memberData?.name || "Member" memberData?.name || "Member"
@ -42,14 +65,14 @@
Dashboard Dashboard
</NuxtLink> </NuxtLink>
<button <button
@click="logout" @click="handleLogout"
class="text-stone-500 hover:text-stone-300 transition-all duration-300 text-sm" class="text-ghost-500 hover:text-ghost-300 transition-all duration-300 text-sm"
> >
Logout Logout
</button> </button>
</div> </div>
<div v-else class="space-y-4"> <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 Enter your email to receive a login link
</p> </p>
@ -97,8 +120,30 @@
<script setup> <script setup>
import { reactive, ref, computed } from "vue"; import { reactive, ref, computed } from "vue";
const props = defineProps({
isMobile: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["navigate"]);
const { isAuthenticated, logout, memberData } = useAuth(); const { isAuthenticated, logout, memberData } = useAuth();
const handleNavigate = () => {
if (props.isMobile) {
emit("navigate");
}
};
const handleLogout = async () => {
await logout();
if (props.isMobile) {
emit("navigate");
}
};
const publicNavigationItems = [ const publicNavigationItems = [
{ label: "Home", path: "/", accent: "entry point" }, { label: "Home", path: "/", accent: "entry point" },
{ label: "About", path: "/about", accent: "who we are" }, { label: "About", path: "/about", accent: "who we are" },
@ -112,7 +157,6 @@ const memberNavigationItems = [
{ label: "Events", path: "/events" }, { label: "Events", path: "/events" },
{ label: "Members", path: "/members" }, { label: "Members", path: "/members" },
{ label: "Resources", path: "/resources" }, { label: "Resources", path: "/resources" },
{ label: "Updates", path: "/updates" },
{ label: "Peer Support", path: "/peer-support" }, { label: "Peer Support", path: "/peer-support" },
{ label: "Profile", path: "/member/profile" }, { label: "Profile", path: "/member/profile" },
]; ];

View file

@ -2,14 +2,14 @@
<div class="space-y-4"> <div class="space-y-4">
<!-- Current Image Preview --> <!-- Current Image Preview -->
<div v-if="modelValue?.url" class="relative"> <div v-if="modelValue?.url" class="relative">
<img <img
:src="transformedImageUrl" :src="transformedImageUrl"
:alt="modelValue.alt || 'Event image'" :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)" @error="console.log('Image failed to load:', transformedImageUrl)"
@load="console.log('Image loaded successfully:', transformedImageUrl)" @load="console.log('Image loaded successfully:', transformedImageUrl)"
/> />
<button <button
@click="removeImage" @click="removeImage"
type="button" type="button"
class="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors" class="absolute top-2 right-2 p-1 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors"
@ -19,7 +19,7 @@
</div> </div>
<!-- Upload Area --> <!-- Upload Area -->
<div <div
v-if="!modelValue?.url" v-if="!modelValue?.url"
class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors" class="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center hover:border-gray-400 transition-colors"
@dragover.prevent="isDragging = true" @dragover.prevent="isDragging = true"
@ -34,12 +34,12 @@
@change="handleFileSelect" @change="handleFileSelect"
class="hidden" class="hidden"
/> />
<div class="space-y-3"> <div class="space-y-3">
<Icon name="heroicons:photo" class="w-12 h-12 text-gray-400 mx-auto" /> <Icon name="heroicons:photo" class="w-12 h-12 text-gray-400 mx-auto" />
<div> <div>
<p class="text-gray-600"> <p class="text-gray-600">
<button <button
type="button" type="button"
@click="$refs.fileInput.click()" @click="$refs.fileInput.click()"
class="text-blue-600 hover:text-blue-500 font-medium" class="text-blue-600 hover:text-blue-500 font-medium"
@ -62,7 +62,7 @@
:value="modelValue.alt || ''" :value="modelValue.alt || ''"
@input="updateAltText($event.target.value)" @input="updateAltText($event.target.value)"
placeholder="Describe this image..." 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> </div>
@ -73,7 +73,7 @@
<span class="text-gray-600">{{ uploadProgress }}%</span> <span class="text-gray-600">{{ uploadProgress }}%</span>
</div> </div>
<div class="w-full bg-gray-200 rounded-full h-2"> <div class="w-full bg-gray-200 rounded-full h-2">
<div <div
class="bg-blue-600 h-2 rounded-full transition-all duration-300" class="bg-blue-600 h-2 rounded-full transition-all duration-300"
:style="`width: ${uploadProgress}%`" :style="`width: ${uploadProgress}%`"
/> />
@ -91,111 +91,113 @@
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: Object, type: Object,
default: () => null default: () => null,
} },
}) });
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(["update:modelValue"]);
const isDragging = ref(false) const isDragging = ref(false);
const isUploading = ref(false) const isUploading = ref(false);
const uploadProgress = ref(0) const uploadProgress = ref(0);
const errorMessage = ref('') const errorMessage = ref("");
const fileInput = ref() const fileInput = ref();
// Transform image URL for preview (smaller size) // Transform image URL for preview (smaller size)
const transformedImageUrl = computed(() => { 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 we have the direct URL, use it
if (props.modelValue?.url) { if (props.modelValue?.url) {
console.log('Using direct URL:', props.modelValue.url) console.log("Using direct URL:", props.modelValue.url);
return props.modelValue.url return props.modelValue.url;
} }
// Otherwise try to construct from publicId // Otherwise try to construct from publicId
if (props.modelValue?.publicId) { if (props.modelValue?.publicId) {
const config = useRuntimeConfig() 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}` 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) console.log("Constructed URL:", constructedUrl);
return constructedUrl return constructedUrl;
} }
console.log('No URL or publicId found') console.log("No URL or publicId found");
return '' return "";
}) });
const handleFileSelect = (event) => { const handleFileSelect = (event) => {
const file = event.target.files[0] const file = event.target.files[0];
if (file) { if (file) {
uploadFile(file) uploadFile(file);
} }
} };
const handleDrop = (event) => { const handleDrop = (event) => {
isDragging.value = false isDragging.value = false;
const files = event.dataTransfer.files const files = event.dataTransfer.files;
if (files.length > 0) { if (files.length > 0) {
uploadFile(files[0]) uploadFile(files[0]);
} }
} };
const uploadFile = async (file) => { const uploadFile = async (file) => {
// Validate file // Validate file
if (!file.type.startsWith('image/')) { if (!file.type.startsWith("image/")) {
errorMessage.value = 'Please select an image file' errorMessage.value = "Please select an image file";
return return;
} }
if (file.size > 10 * 1024 * 1024) { // 10MB if (file.size > 10 * 1024 * 1024) {
errorMessage.value = 'File size must be less than 10MB' // 10MB
return errorMessage.value = "File size must be less than 10MB";
return;
} }
errorMessage.value = '' errorMessage.value = "";
isUploading.value = true isUploading.value = true;
uploadProgress.value = 0 uploadProgress.value = 0;
try { try {
// Create form data for upload // Create form data for upload
const formData = new FormData() const formData = new FormData();
formData.append('file', file) formData.append("file", file);
// Upload to Cloudinary // Upload to Cloudinary
const response = await $fetch(`/api/upload/image`, { const response = await $fetch(`/api/upload/image`, {
method: 'POST', method: "POST",
body: formData, body: formData,
onUploadProgress: (progress) => { 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 // Update the model value
emit('update:modelValue', { emit("update:modelValue", {
url: response.secure_url, url: response.secure_url,
publicId: response.public_id, publicId: response.public_id,
alt: '' alt: "",
}) });
} catch (error) { } catch (error) {
console.error('Upload failed:', error) console.error("Upload failed:", error);
errorMessage.value = 'Upload failed. Please try again.' errorMessage.value = "Upload failed. Please try again.";
} finally { } finally {
isUploading.value = false isUploading.value = false;
uploadProgress.value = 0 uploadProgress.value = 0;
} }
} };
const removeImage = () => { const removeImage = () => {
emit('update:modelValue', null) emit("update:modelValue", null);
} };
const updateAltText = (altText) => { const updateAltText = (altText) => {
emit('update:modelValue', { emit("update:modelValue", {
...props.modelValue, ...props.modelValue,
alt: altText alt: altText,
}) });
} };
</script> </script>

View file

@ -28,21 +28,21 @@
'rounded-2xl p-6 md:p-8 mb-12 backdrop-blur-sm', 'rounded-2xl p-6 md:p-8 mb-12 backdrop-blur-sm',
props.theme === 'ethereal' props.theme === 'ethereal'
? 'bg-ghost-800/60 border border-ghost-700 ethereal-glow halftone-texture' ? '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 <button
:class="[ :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' 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', : 'bg-blue-500 text-white hover:bg-blue-600',
]" ]"
@click="$emit('prev')" @click="$emit('prev')"
> >
<svg <svg
class="w-6 h-6" class="w-5 h-5 md:w-6 md:h-6"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -56,14 +56,14 @@
</svg> </svg>
</button> </button>
<div class="text-center flex-1"> <div class="text-center flex-1 min-w-0">
<slot name="interactive-content"> <slot name="interactive-content">
<p <p
:class="[ :class="[
'text-lg', 'text-base md:text-lg',
props.theme === 'ethereal' props.theme === 'ethereal'
? 'text-stone-200' ? 'text-ghost-200'
: 'text-gray-600 dark:text-gray-300', : 'text-[--ui-text-muted]',
]" ]"
> >
{{ {{
@ -76,15 +76,15 @@
<button <button
:class="[ :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' 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', : 'bg-blue-500 text-white hover:bg-blue-600',
]" ]"
@click="$emit('next')" @click="$emit('next')"
> >
<svg <svg
class="w-6 h-6" class="w-5 h-5 md:w-6 md:h-6"
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
@ -178,12 +178,10 @@ defineEmits(["prev", "next"]);
const backgroundClass = computed(() => { const backgroundClass = computed(() => {
const themes = { const themes = {
blue: "bg-gradient-to-br from-blue-50 to-indigo-100 dark:from-gray-900 dark:to-gray-800", blue: "bg-gradient-to-br from-blue-50 to-indigo-100",
purple: purple: "bg-gradient-to-br from-purple-50 to-violet-100",
"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",
emerald: gray: "bg-neutral-100",
"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",
ethereal: ethereal:
"bg-gradient-to-br from-ghost-900 via-ghost-800 to-whisper-900 halftone-texture", "bg-gradient-to-br from-ghost-900 via-ghost-800 to-whisper-900 halftone-texture",
}; };

View file

@ -1,25 +1,22 @@
<template> <template>
<div class="flex items-center gap-2 text-sm"> <div class="flex items-center gap-3 text-sm">
<span class="text-gray-600 dark:text-gray-400">{{ label }}:</span> <span class="text-ghost-300 font-medium">{{ label }}:</span>
<UButtonGroup size="xs"> <UButtonGroup size="sm" class="privacy-toggle-group">
<UButton <UButton
:variant="modelValue === 'public' ? 'solid' : 'ghost'" :variant="modelValue === 'members' ? 'solid' : 'outline'"
:color="modelValue === 'public' ? 'blue' : 'gray'" :color="modelValue === 'members' ? 'blue' : 'neutral'"
@click="updateValue('public')"
>
Public
</UButton>
<UButton
:variant="modelValue === 'members' ? 'solid' : 'ghost'"
:color="modelValue === 'members' ? 'blue' : 'gray'"
@click="updateValue('members')" @click="updateValue('members')"
class="privacy-toggle-btn"
:class="{ 'is-selected': modelValue === 'members' }"
> >
Members Members
</UButton> </UButton>
<UButton <UButton
:variant="modelValue === 'private' ? 'solid' : 'ghost'" :variant="modelValue === 'private' ? 'solid' : 'outline'"
:color="modelValue === 'private' ? 'blue' : 'gray'" :color="modelValue === 'private' ? 'blue' : 'neutral'"
@click="updateValue('private')" @click="updateValue('private')"
class="privacy-toggle-btn"
:class="{ 'is-selected': modelValue === 'private' }"
> >
Private Private
</UButton> </UButton>
@ -31,17 +28,43 @@
const props = defineProps({ const props = defineProps({
modelValue: { modelValue: {
type: String, type: String,
default: 'members' default: "members",
}, },
label: { label: {
type: String, type: String,
default: 'Privacy' default: "Privacy",
} },
}) });
const emit = defineEmits(['update:modelValue']) const emit = defineEmits(["update:modelValue"]);
const updateValue = (value) => { const updateValue = (value) => {
emit('update:modelValue', value) emit("update:modelValue", value);
} };
</script> </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>

View file

@ -12,7 +12,7 @@
/> />
<div <div
v-else 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() || "?" }} {{ update.author?.name?.charAt(0)?.toUpperCase() || "?" }}
</div> </div>
@ -23,30 +23,30 @@
<!-- Header --> <!-- Header -->
<div class="flex items-start justify-between gap-4 mb-2"> <div class="flex items-start justify-between gap-4 mb-2">
<div> <div>
<h3 class="font-semibold text-stone-100"> <h3 class="font-semibold text-ghost-100">
<NuxtLink <NuxtLink
v-if="update.author?._id" v-if="update.author?._id"
:to="`/updates/user/${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 }} {{ update.author.name }}
</NuxtLink> </NuxtLink>
<span v-else>Unknown Member</span> <span v-else>Unknown Member</span>
</h3> </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"> <time :datetime="update.createdAt">
{{ formatDate(update.createdAt) }} {{ formatDate(update.createdAt) }}
</time> </time>
<span v-if="isEdited" class="text-stone-500">(edited)</span> <span v-if="isEdited" class="text-ghost-500">(edited)</span>
<span <span
v-if="update.privacy === 'private'" 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 Private
</span> </span>
<span <span
v-if="update.privacy === 'public'" 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 Public
</span> </span>
@ -73,12 +73,12 @@
</div> </div>
<!-- Content --> <!-- 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"> <template v-if="showPreview && update.content.length > 300">
{{ update.content.substring(0, 300) }}... {{ update.content.substring(0, 300) }}...
<NuxtLink <NuxtLink
:to="`/updates/${update._id}`" :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 Read more
</NuxtLink> </NuxtLink>
@ -100,14 +100,14 @@
</div> </div>
<!-- Footer actions --> <!-- 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 <NuxtLink
:to="`/updates/${update._id}`" :to="`/updates/${update._id}`"
class="hover:text-stone-300 transition-colors" class="hover:text-ghost-300 transition-colors"
> >
View full update View full update
</NuxtLink> </NuxtLink>
<span v-if="update.commentsEnabled" class="text-stone-500"> <span v-if="update.commentsEnabled" class="text-ghost-500">
Comments (coming soon) Comments (coming soon)
</span> </span>
</div> </div>

View file

@ -11,19 +11,19 @@
</UFormField> </UFormField>
<!-- Privacy Settings --> <!-- Privacy Settings -->
<div class="border border-stone-700 rounded-lg p-4 bg-stone-800/30"> <div class="border border-ghost-700 rounded-lg p-4 bg-ghost-800/30">
<h3 class="text-sm font-medium text-stone-200 mb-4">Privacy Settings</h3> <h3 class="text-sm font-medium text-ghost-200 mb-4">Privacy Settings</h3>
<div class="space-y-3"> <div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer"> <label class="flex items-center gap-3 cursor-pointer">
<input <input
v-model="formData.privacy" v-model="formData.privacy"
type="radio" type="radio"
value="public" value="public"
class="w-4 h-4 text-stone-400" class="w-4 h-4 text-ghost-400"
/> />
<div> <div>
<div class="text-stone-200 font-medium">Public</div> <div class="text-ghost-200 font-medium">Public</div>
<div class="text-sm text-stone-400"> <div class="text-sm text-ghost-400">
Visible to everyone, including non-members Visible to everyone, including non-members
</div> </div>
</div> </div>
@ -34,11 +34,11 @@
v-model="formData.privacy" v-model="formData.privacy"
type="radio" type="radio"
value="members" value="members"
class="w-4 h-4 text-stone-400" class="w-4 h-4 text-ghost-400"
/> />
<div> <div>
<div class="text-stone-200 font-medium">Members Only</div> <div class="text-ghost-200 font-medium">Members Only</div>
<div class="text-sm text-stone-400"> <div class="text-sm text-ghost-400">
Only visible to Ghost Guild members Only visible to Ghost Guild members
</div> </div>
</div> </div>
@ -49,11 +49,11 @@
v-model="formData.privacy" v-model="formData.privacy"
type="radio" type="radio"
value="private" value="private"
class="w-4 h-4 text-stone-400" class="w-4 h-4 text-ghost-400"
/> />
<div> <div>
<div class="text-stone-200 font-medium">Private</div> <div class="text-ghost-200 font-medium">Private</div>
<div class="text-sm text-stone-400">Only visible to you</div> <div class="text-sm text-ghost-400">Only visible to you</div>
</div> </div>
</label> </label>
</div> </div>
@ -66,15 +66,17 @@
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<USwitch v-model="formData.commentsEnabled" /> <USwitch v-model="formData.commentsEnabled" />
<div> <div>
<div class="text-stone-200 font-medium">Enable Comments</div> <div class="text-ghost-200 font-medium">Enable Comments</div>
<div class="text-sm text-stone-400"> <div class="text-sm text-ghost-400">
Allow members to comment on this update Allow members to comment on this update
</div> </div>
</div> </div>
</div> </div>
<!-- Actions --> <!-- 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')"> <UButton variant="ghost" color="neutral" @click="$emit('cancel')">
Cancel Cancel
</UButton> </UButton>

View file

@ -1,46 +1,54 @@
export const useAuth = () => { 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 () => { const checkMemberStatus = async () => {
console.log('🔍 checkMemberStatus called') console.log("🔍 checkMemberStatus called");
console.log(' - Current memberData:', !!memberData.value) console.log(" - Current memberData:", !!memberData.value);
try { try {
console.log(' - Making API call to /api/auth/member...') console.log(" - Making API call to /api/auth/member...");
const response = await $fetch('/api/auth/member') const response = await $fetch("/api/auth/member");
console.log(' - API response received:', { email: response.email, id: response.id }) console.log(" - API response received:", {
memberData.value = response email: response.email,
console.log(' - ✅ Member authenticated successfully') id: response.id,
return true });
memberData.value = response;
console.log(" - ✅ Member authenticated successfully");
return true;
} catch (error) { } catch (error) {
console.error(' - ❌ Failed to fetch member status:', error.statusCode, error.statusMessage) console.error(
memberData.value = null " - ❌ Failed to fetch member status:",
console.log(' - Cleared memberData') error.statusCode,
return false error.statusMessage,
);
memberData.value = null;
console.log(" - Cleared memberData");
return false;
} }
} };
const logout = async () => { const logout = async () => {
try { try {
await $fetch('/api/auth/logout', { await $fetch("/api/auth/logout", {
method: 'POST' method: "POST",
}) });
memberData.value = null memberData.value = null;
await navigateTo('/') await navigateTo("/");
} catch (error) { } catch (error) {
console.error('Logout failed:', error) console.error("Logout failed:", error);
} }
} };
return { return {
isAuthenticated: readonly(isAuthenticated), isAuthenticated: readonly(isAuthenticated),
isMember: readonly(isMember), isMember: readonly(isMember),
memberData: readonly(memberData), memberData: readonly(memberData),
checkMemberStatus, checkMemberStatus,
logout fetchMember: checkMemberStatus, // Alias for consistency
} logout,
} };
};

View 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 };
};

View file

@ -1,5 +1,5 @@
<template> <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 --> <!-- Background image at top - full page width -->
<div <div
class="absolute inset-x-0 pointer-events-none z-0" 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 --> <!-- Main Content Column - Left -->
<div class="flex-1 overflow-y-auto relative z-[5]"> <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 /> <slot />
</div> </div>
<AppFooter /> <AppFooter />
</div> </div>
<!-- Navigation Column - Right --> <!-- Desktop Navigation Column - Right -->
<AppNavigation class="relative z-20" /> <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> </div>
</template> </template>
<script setup>
const isMobileMenuOpen = ref(false);
</script>

View file

@ -9,22 +9,22 @@
/> />
<!-- How Ghost Guild Works --> <!-- How Ghost Guild Works -->
<section class="py-20 bg-white dark:bg-gray-900"> <section class="py-20 bg-[--ui-bg]">
<UContainer> <UContainer>
<div class="max-w-4xl mx-auto"> <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 How Ghost Guild Works
</h2> </h2>
<div class="prose prose-lg dark:prose-invert max-w-none"> <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 Everyone gets everything. Your circle reflects where you are in
your cooperative journey. Your financial contribution reflects your cooperative journey. Your financial contribution reflects
what you can afford. These are completely separate choices. what you can afford. These are completely separate choices.
</p> </p>
<ul <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> <li>
<strong>Equal access:</strong> The entire knowledge commons, all <strong>Equal access:</strong> The entire knowledge commons, all
@ -49,13 +49,13 @@
</section> </section>
<!-- Find Your Circle --> <!-- Find Your Circle -->
<section class="py-20 bg-gray-50 dark:bg-gray-800"> <section class="py-20 bg-[--ui-bg-elevated]">
<UContainer> <UContainer>
<div class="max-w-4xl mx-auto"> <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 Find Your Circle
</h2> </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 Circles help us provide relevant guidance and connect you with
others at similar stages. Choose based on where you are, not what others at similar stages. Choose based on where you are, not what
you want to access. you want to access.
@ -63,21 +63,19 @@
<div class="space-y-12"> <div class="space-y-12">
<!-- Community Circle --> <!-- Community Circle -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8"> <div class="bg-[--ui-bg] rounded-xl p-8">
<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">
Community Circle Community Circle
</h3> </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 You're exploring what cooperatives could mean for your work
</p> </p>
<div class="mb-6"> <div class="mb-6">
<h4 <h4 class="text-lg font-semibold text-[--ui-text] mb-3">
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
Where you might be: Where you might be:
</h4> </h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2"> <ul class="text-[--ui-text-muted] space-y-2">
<li> <li>
Curious about alternatives to traditional studio structures Curious about alternatives to traditional studio structures
</li> </li>
@ -88,12 +86,10 @@
</div> </div>
<div class="mb-6"> <div class="mb-6">
<h4 <h4 class="text-lg font-semibold text-[--ui-text] mb-3">
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
We'll help you navigate: We'll help you navigate:
</h4> </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>Understanding cooperative basics</li>
<li>Connecting with others asking similar questions</li> <li>Connecting with others asking similar questions</li>
<li>Exploring real examples from game studios</li> <li>Exploring real examples from game studios</li>
@ -102,12 +98,10 @@
</div> </div>
<div> <div>
<h4 <h4 class="text-lg font-semibold text-[--ui-text] mb-3">
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
You might be: You might be:
</h4> </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>Individual game workers</li>
<li>Researchers and students</li> <li>Researchers and students</li>
<li>Industry allies and supporters</li> <li>Industry allies and supporters</li>
@ -117,11 +111,11 @@
</div> </div>
<!-- Founder Circle --> <!-- Founder Circle -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8"> <div class="bg-[--ui-bg] rounded-xl p-8">
<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">
Founder Circle Founder Circle
</h3> </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 You're actively building or transitioning to a cooperative model
</p> </p>
@ -188,11 +182,11 @@
</div> </div>
<!-- Practitioner Circle --> <!-- Practitioner Circle -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-8"> <div class="bg-[--ui-bg] rounded-xl p-8">
<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">
Practitioner Circle Practitioner Circle
</h3> </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 You're operating a cooperative and contributing to the field
</p> </p>
@ -246,14 +240,14 @@
</section> </section>
<!-- Important Notes --> <!-- Important Notes -->
<section class="py-20 bg-white dark:bg-gray-900"> <section class="py-20 bg-[--ui-bg]">
<UContainer> <UContainer>
<div class="max-w-4xl mx-auto"> <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 Important Notes
</h2> </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> <p>
<strong>Movement between circles is fluid.</strong> As you move <strong>Movement between circles is fluid.</strong> As you move
along in your journey, you can shift circles anytime. Just let us along in your journey, you can shift circles anytime. Just let us

View file

@ -4,13 +4,13 @@
<section class="mb-24"> <section class="mb-24">
<div class="relative"> <div class="relative">
<h1 <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 Get in Touch
</h1> </h1>
<div class="mb-16"> <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 /> We'd be happy to answer any questions<br />
you might have about Ghost Guild you might have about Ghost Guild
</p> </p>
@ -21,7 +21,7 @@
<!-- Contact Form --> <!-- Contact Form -->
<section class="mb-32 relative"> <section class="mb-32 relative">
<div class="mb-12"> <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) Send us a message (or email hello@ghostguild.org)
</h2> </h2>
</div> </div>
@ -79,7 +79,7 @@
:loading="isSubmitting" :loading="isSubmitting"
:disabled="!isFormValid" :disabled="!isFormValid"
size="xl" 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 Send Message
</UButton> </UButton>
@ -98,7 +98,7 @@
</div> </div>
<div v-if="error" class="mt-6 p-4 border border-ghost-700 bg-ghost-900"> <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 }} {{ error }}
</p> </p>
</div> </div>

View file

@ -1,27 +1,27 @@
<template> <template>
<div <div
v-if="pending" 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="text-center">
<div <div
class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4" class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto mb-4"
></div> ></div>
<p class="text-stone-200">Loading event details...</p> <p class="text-ghost-200">Loading event details...</p>
</div> </div>
</div> </div>
<div <div
v-else-if="error" 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"> <div class="text-center">
<Icon <Icon
name="heroicons:exclamation-triangle" name="heroicons:exclamation-triangle"
class="w-16 h-16 text-red-500 mx-auto mb-4" 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> <h2 class="text-2xl font-bold text-ghost-100 mb-2">Event Not Found</h2>
<p class="text-stone-300 mb-6"> <p class="text-ghost-300 mb-6">
The event you're looking for doesn't exist. The event you're looking for doesn't exist.
</p> </p>
<NuxtLink to="/events" class="text-blue-400 hover:underline"> <NuxtLink to="/events" class="text-blue-400 hover:underline">
@ -64,11 +64,11 @@
<PageHeader v-else :title="event.title" theme="blue" size="medium" /> <PageHeader v-else :title="event.title" theme="blue" size="medium" />
<!-- Event Details Section --> <!-- Event Details Section -->
<section class="py-16 bg-stone-900"> <section class="py-16 bg-ghost-900">
<UContainer> <UContainer>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<!-- Event Meta Info --> <!-- 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="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<Icon <Icon
@ -76,8 +76,8 @@
class="w-6 h-6 text-blue-400" class="w-6 h-6 text-blue-400"
/> />
<div> <div>
<p class="text-sm text-stone-400">Date</p> <p class="text-sm text-ghost-400">Date</p>
<p class="font-semibold text-stone-100"> <p class="font-semibold text-ghost-100">
{{ formatDate(event.startDate) }} {{ formatDate(event.startDate) }}
</p> </p>
</div> </div>
@ -86,8 +86,8 @@
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<Icon name="heroicons:clock" class="w-6 h-6 text-blue-400" /> <Icon name="heroicons:clock" class="w-6 h-6 text-blue-400" />
<div> <div>
<p class="text-sm text-stone-400">Time</p> <p class="text-sm text-ghost-400">Time</p>
<p class="font-semibold text-stone-100"> <p class="font-semibold text-ghost-100">
{{ formatTime(event.startDate, event.endDate) }} {{ formatTime(event.startDate, event.endDate) }}
</p> </p>
</div> </div>
@ -96,8 +96,8 @@
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<Icon name="heroicons:map-pin" class="w-6 h-6 text-blue-400" /> <Icon name="heroicons:map-pin" class="w-6 h-6 text-blue-400" />
<div> <div>
<p class="text-sm text-stone-400">Location</p> <p class="text-sm text-ghost-400">Location</p>
<p class="font-semibold text-stone-100"> <p class="font-semibold text-ghost-100">
{{ event.location }} {{ event.location }}
</p> </p>
</div> </div>
@ -153,7 +153,7 @@
> >
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<Icon name="heroicons:user-group" class="w-5 h-5 text-blue-400" /> <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 >Recommended for:</span
> >
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
@ -170,15 +170,15 @@
<!-- Event Description --> <!-- Event Description -->
<div class="prose prose-lg dark:prose-invert max-w-none mb-12"> <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 About This Event
</h2> </h2>
<p class="text-stone-200"> <p class="text-ghost-200">
{{ event.description }} {{ event.description }}
</p> </p>
<div v-if="event.agenda && event.agenda.length > 0" class="mt-8"> <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 Event Agenda
</h3> </h3>
<ul class="space-y-3"> <ul class="space-y-3">
@ -192,7 +192,7 @@
> >
{{ index + 1 }} {{ index + 1 }}
</span> </span>
<span class="text-stone-200">{{ item }}</span> <span class="text-ghost-200">{{ item }}</span>
</li> </li>
</ul> </ul>
</div> </div>
@ -201,7 +201,7 @@
v-if="event.speakers && event.speakers.length > 0" v-if="event.speakers && event.speakers.length > 0"
class="mt-8" 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 Speakers
</h3> </h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
@ -211,21 +211,21 @@
class="flex items-start space-x-4" class="flex items-start space-x-4"
> >
<div <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 <Icon
name="heroicons:user" name="heroicons:user"
class="w-8 h-8 text-stone-500" class="w-8 h-8 text-ghost-500"
/> />
</div> </div>
<div> <div>
<p class="font-semibold text-stone-100"> <p class="font-semibold text-ghost-100">
{{ speaker.name }} {{ speaker.name }}
</p> </p>
<p class="text-sm text-stone-300"> <p class="text-sm text-ghost-300">
{{ speaker.role }} {{ speaker.role }}
</p> </p>
<p class="text-sm text-stone-400 mt-1"> <p class="text-sm text-ghost-400 mt-1">
{{ speaker.bio }} {{ speaker.bio }}
</p> </p>
</div> </div>
@ -237,9 +237,9 @@
<!-- Registration Section --> <!-- Registration Section -->
<div <div
v-if="!event.isCancelled" 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 Register for This Event
</h3> </h3>
@ -322,7 +322,7 @@
<div> <div>
<label <label
for="name" 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 Full Name
</label> </label>
@ -338,7 +338,7 @@
<div> <div>
<label <label
for="email" 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 Email Address
</label> </label>
@ -354,7 +354,7 @@
<div> <div>
<label <label
for="membershipLevel" 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 Membership Status
</label> </label>
@ -388,16 +388,16 @@
<!-- Event Capacity --> <!-- Event Capacity -->
<div <div
v-if="event.maxAttendees" 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"> <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"> <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 }} {{ event.registeredCount || 0 }} / {{ event.maxAttendees }}
</span> </span>
<div <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 <div
class="h-full bg-blue-500 rounded-full" class="h-full bg-blue-500 rounded-full"
@ -410,9 +410,9 @@
</div> </div>
<!-- Additional Information --> <!-- Additional Information -->
<div class="mt-8 p-6 bg-stone-800 rounded-xl border border-stone-700"> <div class="mt-8 p-6 bg-ghost-800 rounded-xl border border-ghost-700">
<h4 class="font-semibold text-stone-100 mb-3">Questions?</h4> <h4 class="font-semibold text-ghost-100 mb-3">Questions?</h4>
<p class="text-sm text-stone-200 mb-3"> <p class="text-sm text-ghost-200 mb-3">
If you have any questions about this event please drop us a line. If you have any questions about this event please drop us a line.
</p> </p>
<a <a

View file

@ -8,7 +8,7 @@
/> />
<!-- Events Section with Tabs --> <!-- 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> <UContainer>
<UTabs <UTabs
v-model="activeTab" v-model="activeTab"
@ -27,10 +27,10 @@
class="group flex items-start gap-4 py-2 hover:opacity-80 transition-opacity" class="group flex items-start gap-4 py-2 hover:opacity-80 transition-opacity"
> >
<div class="flex-shrink-0 text-center"> <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() }} {{ event.start.getDate() }}
</div> </div>
<div class="text-xs text-stone-400 uppercase"> <div class="text-xs text-ghost-400 uppercase">
{{ {{
event.start.toLocaleDateString("en-US", { event.start.toLocaleDateString("en-US", {
month: "short", month: "short",
@ -42,7 +42,7 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-start gap-2 mb-1"> <div class="flex items-start gap-2 mb-1">
<h3 <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 }} {{ event.title }}
</h3> </h3>
@ -53,7 +53,7 @@
/> />
</div> </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 }} {{ event.content }}
</p> </p>
@ -72,7 +72,7 @@
<Icon <Icon
name="heroicons:arrow-right" 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> </NuxtLink>
</div> </div>
@ -83,10 +83,10 @@
<ClientOnly> <ClientOnly>
<div <div
v-if="pending" 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"> <div class="text-center">
<p class="text-stone-200">Loading events...</p> <p class="text-ghost-200">Loading events...</p>
</div> </div>
</div> </div>
<VueCal <VueCal
@ -110,13 +110,13 @@
/> />
<template #fallback> <template #fallback>
<div <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="text-center">
<div <div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4" class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"
></div> ></div>
<p class="text-stone-200">Loading calendar...</p> <p class="text-ghost-200">Loading calendar...</p>
</div> </div>
</div> </div>
</template> </template>
@ -130,14 +130,14 @@
<!-- Event Series --> <!-- Event Series -->
<section <section
v-if="activeSeries.length > 0" 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> <UContainer>
<div class="text-center mb-12"> <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 Active Event Series
</h2> </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 Multi-part workshops and recurring events designed to deepen your
knowledge and build community connections. knowledge and build community connections.
</p> </p>
@ -149,7 +149,7 @@
<div <div
v-for="series in activeSeries.slice(0, 6)" v-for="series in activeSeries.slice(0, 6)"
:key="series.id" :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 class="flex items-start justify-between mb-4">
<div <div
@ -160,17 +160,17 @@
> >
{{ formatSeriesType(series.type) }} {{ formatSeriesType(series.type) }}
</div> </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" /> <Icon name="heroicons:calendar-days" class="w-4 h-4" />
<span>{{ series.eventCount }} events</span> <span>{{ series.eventCount }} events</span>
</div> </div>
</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 }} {{ series.title }}
</h3> </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 }} {{ series.description }}
</p> </p>
@ -186,22 +186,22 @@
> >
{{ event.series?.position || "?" }} {{ event.series?.position || "?" }}
</div> </div>
<span class="text-stone-300 truncate">{{ event.title }}</span> <span class="text-ghost-300 truncate">{{ event.title }}</span>
</div> </div>
<span class="text-stone-400"> <span class="text-ghost-400">
{{ formatEventDate(event.startDate) }} {{ formatEventDate(event.startDate) }}
</span> </span>
</div> </div>
<div <div
v-if="series.events.length > 3" 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 +{{ series.events.length - 3 }} more events
</div> </div>
</div> </div>
<div class="flex items-center justify-between text-sm"> <div class="flex items-center justify-between text-sm">
<div class="text-stone-400"> <div class="text-ghost-400">
{{ formatDateRange(series.startDate, series.endDate) }} {{ formatDateRange(series.startDate, series.endDate) }}
</div> </div>
<span <span
@ -223,20 +223,20 @@
</section> </section>
<!-- Attend Our Events --> <!-- 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> <UContainer>
<div class="text-center mb-16"> <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 Attend Our Events
</h2> </h2>
</div> </div>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div <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"> <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 Our events are ,Lorem ipsum, dolor sit amet consectetur
adipisicing elit. Quibusdam exercitationem delectus ab adipisicing elit. Quibusdam exercitationem delectus ab
voluptates aspernatur, quia deleniti aut maxime, veniam voluptates aspernatur, quia deleniti aut maxime, veniam
@ -244,7 +244,7 @@
dolorum alias nulla! dolorum alias nulla!
</p> </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 Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do
eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut
enim ad minim veniam, quis nostrud exercitation ullamco laboris 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="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="text-center"> <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 Monthly Meetups
</h3> </h3>
<p class="text-sm text-stone-300"> <p class="text-sm text-ghost-300">
Casual knowledge sharing sessions Casual knowledge sharing sessions
</p> </p>
</div> </div>
<div class="text-center"> <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 Workshops
</h3> </h3>
<p class="text-sm text-stone-300"> <p class="text-sm text-ghost-300">
Hands-on learning about cooperative and worker-centric business Hands-on learning about cooperative and worker-centric business
models models
</p> </p>
</div> </div>
<div class="text-center"> <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 Social Events
</h3> </h3>
<p class="text-sm text-stone-300"> <p class="text-sm text-ghost-300">
Game nights, socials, and more Game nights, socials, and more
</p> </p>
</div> </div>

View file

@ -5,14 +5,14 @@
<div class="relative"> <div class="relative">
<!-- Large artistic title --> <!-- Large artistic title -->
<h1 <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 Become a Ghostie
</h1> </h1>
<!-- Floating subtitle --> <!-- Floating subtitle -->
<div class="mb-16"> <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 /> A community for creatives and game devs<br />
exploring cooperative models exploring cooperative models
</p> </p>
@ -33,7 +33,7 @@
<div> <div>
<NuxtLink <NuxtLink
to="/join" 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 Join Us Today
</NuxtLink> </NuxtLink>
@ -55,13 +55,13 @@
> >
<!-- Content --> <!-- Content -->
<div class="flex-1 max-w-lg"> <div class="flex-1 max-w-lg">
<h3 class="text-xl text-stone-100 mb-3">{{ circle.label }}</h3> <h3 class="text-xl text-ghost-100 mb-3">{{ circle.label }}</h3>
<p class="text-stone-200 text-sm leading-relaxed mb-4"> <p class="text-ghost-200 text-sm leading-relaxed mb-4">
{{ circle.description }} {{ circle.description }}
</p> </p>
<!-- Features as inline text --> <!-- 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"> <span v-for="(feature, i) in circle.features" :key="feature">
{{ feature {{ feature
}}<span v-if="i < circle.features.length - 1"> </span> }}<span v-if="i < circle.features.length - 1"> </span>
@ -77,7 +77,7 @@
<!-- Why Join? - Diagonal Layout --> <!-- Why Join? - Diagonal Layout -->
<section class="mb-32 relative"> <section class="mb-32 relative">
<div class="transform -rotate-1"> <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>
<div class="ml-12 relative"> <div class="ml-12 relative">
@ -86,11 +86,11 @@
/> />
<div class="max-w-2xl"> <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. Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p> </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 Sed do eiusmod tempor incididunt ut labore et dolore magna
aliqua.<br /> aliqua.<br />
Ut enim ad minim veniam, quis nostrud exercitation. Ut enim ad minim veniam, quis nostrud exercitation.
@ -98,7 +98,7 @@
</div> </div>
<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> </div>

View file

@ -9,13 +9,13 @@
/> />
<!-- Membership Sign Up Form --> <!-- 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"> <UContainer class="max-w-4xl">
<div class="text-center mb-12"> <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 Membership Sign Up
</h2> </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 Choose your circle to connect with others at your stage. Choose your
contribution based on what you can afford. Everyone gets full contribution based on what you can afford. Everyone gets full
access. access.
@ -30,8 +30,8 @@
:class="[ :class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold', 'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 1 currentStep >= 1
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900' ? 'bg-neutral-900 text-neutral-50'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500', : 'bg-neutral-200 text-neutral-500',
]" ]"
> >
1 1
@ -39,21 +39,16 @@
<span <span
class="ml-2 font-medium" class="ml-2 font-medium"
:class=" :class="
currentStep === 1 currentStep === 1 ? 'text-[--ui-text]' : 'text-neutral-500'
? 'text-gray-900 dark:text-white'
: 'text-gray-500'
" "
> >
Information Information
</span> </span>
</div> </div>
<div <div v-if="needsPayment" class="w-16 h-1 bg-neutral-200">
v-if="needsPayment"
class="w-16 h-1 bg-gray-200 dark:bg-gray-700"
>
<div <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%' }" :style="{ width: currentStep >= 2 ? '100%' : '0%' }"
/> />
</div> </div>
@ -63,8 +58,8 @@
:class="[ :class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold', 'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 2 currentStep >= 2
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900' ? 'bg-neutral-900 text-neutral-50'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500', : 'bg-neutral-200 text-neutral-500',
]" ]"
> >
2 2
@ -72,18 +67,16 @@
<span <span
class="ml-2 font-medium" class="ml-2 font-medium"
:class=" :class="
currentStep === 2 currentStep === 2 ? 'text-[--ui-text]' : 'text-neutral-500'
? 'text-gray-900 dark:text-white'
: 'text-gray-500'
" "
> >
Payment Payment
</span> </span>
</div> </div>
<div class="w-16 h-1 bg-gray-200 dark:bg-gray-700"> <div class="w-16 h-1 bg-neutral-200">
<div <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%' }" :style="{ width: currentStep >= 3 ? '100%' : '0%' }"
/> />
</div> </div>
@ -93,8 +86,8 @@
:class="[ :class="[
'w-10 h-10 rounded-full flex items-center justify-center font-semibold', 'w-10 h-10 rounded-full flex items-center justify-center font-semibold',
currentStep >= 3 currentStep >= 3
? 'bg-gray-900 dark:bg-white text-white dark:text-gray-900' ? 'bg-neutral-900 text-neutral-50'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500', : 'bg-neutral-200 text-neutral-500',
]" ]"
> >
<span v-if="needsPayment">3</span> <span v-if="needsPayment">3</span>
@ -103,9 +96,7 @@
<span <span
class="ml-2 font-medium" class="ml-2 font-medium"
:class=" :class="
currentStep === 3 currentStep === 3 ? 'text-[--ui-text]' : 'text-neutral-500'
? 'text-gray-900 dark:text-white'
: 'text-gray-500'
" "
> >
Confirmation Confirmation
@ -120,7 +111,7 @@
</div> </div>
<!-- Step 1: Information --> <!-- 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"> <UForm :state="form" class="space-y-8" @submit="handleSubmit">
<!-- Personal Information --> <!-- Personal Information -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <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="flex flex-col p-6 rounded-lg border-2 cursor-pointer transition-all hover:shadow-md"
:class=" :class="
form.circle === option.value form.circle === option.value
? 'border-gray-900 dark:border-white bg-gray-50 dark:bg-gray-800' ? 'border-neutral-900 bg-[--ui-bg]'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500' : 'border-neutral-200 hover:border-neutral-400'
" "
> >
<input <input
@ -166,12 +157,12 @@
class="mb-3" class="mb-3"
/> />
<div class="font-medium text-lg mb-2">{{ option.label }}</div> <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 }} {{ option.description }}
</div> </div>
</label> </label>
</div> </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. Not sure? Start with Community - you can always move.
</p> </p>
</div> </div>
@ -213,23 +204,23 @@
<!-- Step 2: Payment --> <!-- Step 2: Payment -->
<div <div
v-if="currentStep === 2" 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"> <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 Payment Information
</h3> </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 You're signing up for the {{ selectedTier.label }} plan
</p> </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 ${{ selectedTier.amount }} CAD / month
</p> </p>
</div> </div>
<!-- Payment Instructions --> <!-- Payment Instructions -->
<div class="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 mb-6"> <div class="bg-[--ui-bg] rounded-lg p-6 mb-6">
<p class="text-gray-700 dark:text-gray-300"> <p class="text-[--ui-text]">
Click "Complete Payment" below to open the secure payment modal Click "Complete Payment" below to open the secure payment modal
and verify your payment method. and verify your payment method.
</p> </p>
@ -254,11 +245,11 @@
<!-- Step 3: Confirmation --> <!-- Step 3: Confirmation -->
<div <div
v-if="currentStep === 3" 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="text-center">
<div <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 <svg
class="w-10 h-10 text-green-500" class="w-10 h-10 text-green-500"
@ -275,7 +266,7 @@
</svg> </svg>
</div> </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! Welcome to Ghost Guild!
</h3> </h3>
@ -283,43 +274,39 @@
<UAlert color="green" :title="successMessage" /> <UAlert color="green" :title="successMessage" />
</div> </div>
<div <div class="bg-[--ui-bg] rounded-lg p-6 mb-6 text-left">
class="bg-gray-50 dark:bg-gray-700 rounded-lg p-6 mb-6 text-left"
>
<h4 class="font-semibold mb-3">Membership Details:</h4> <h4 class="font-semibold mb-3">Membership Details:</h4>
<dl class="space-y-2"> <dl class="space-y-2">
<div class="flex justify-between"> <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> <dd class="font-medium">{{ form.name }}</dd>
</div> </div>
<div class="flex justify-between"> <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> <dd class="font-medium">{{ form.email }}</dd>
</div> </div>
<div class="flex justify-between"> <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> <dd class="font-medium capitalize">{{ form.circle }}</dd>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-gray-600 dark:text-gray-400"> <dt class="text-[--ui-text-muted]">Contribution:</dt>
Contribution:
</dt>
<dd class="font-medium">{{ selectedTier.label }}</dd> <dd class="font-medium">{{ selectedTier.label }}</dd>
</div> </div>
<div v-if="customerCode" class="flex justify-between"> <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> <dd class="font-medium">{{ customerCode }}</dd>
</div> </div>
</dl> </dl>
</div> </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 We've sent a confirmation email to {{ form.email }} with your
membership details. membership details.
</p> </p>
<div class="bg-gray-100 dark:bg-gray-800 rounded-lg p-4 mb-8"> <div class="bg-[--ui-bg] rounded-lg p-4 mb-8">
<p class="text-gray-700 dark:text-gray-300 text-center"> <p class="text-[--ui-text] text-center">
You will be automatically redirected to your dashboard in a few You will be automatically redirected to your dashboard in a few
seconds... seconds...
</p> </p>
@ -339,14 +326,14 @@
</section> </section>
<!-- How Ghost Guild Works --> <!-- How Ghost Guild Works -->
<section class="py-20 bg-gray-50 dark:bg-gray-800"> <section class="py-20 bg-[--ui-bg-elevated]">
<UContainer> <UContainer>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="text-center mb-12"> <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 How Ghost Guild Works
</h2> </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 Every member gets everything. Your circle helps you find relevant
content and peers. Your contribution helps sustain our solidarity content and peers. Your contribution helps sustain our solidarity
economy. economy.
@ -355,13 +342,11 @@
<div class="grid grid-cols-1 md:grid-cols-2 gap-8"> <div class="grid grid-cols-1 md:grid-cols-2 gap-8">
<!-- Full Access --> <!-- Full Access -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-6"> <div class="bg-[--ui-bg] rounded-xl p-6">
<h3 <h3 class="text-xl font-semibold mb-4 text-[--ui-text]">
class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"
>
Full Access Full Access
</h3> </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>Complete resource library</li>
<li>All workshops and events</li> <li>All workshops and events</li>
<li>Slack community</li> <li>Slack community</li>
@ -371,13 +356,11 @@
</div> </div>
<!-- Circle-Specific Guidance --> <!-- Circle-Specific Guidance -->
<div class="bg-white dark:bg-gray-900 rounded-xl p-6"> <div class="bg-[--ui-bg] rounded-xl p-6">
<h3 <h3 class="text-xl font-semibold mb-4 text-[--ui-text]">
class="text-xl font-semibold mb-4 text-gray-900 dark:text-white"
>
Circle-Specific Guidance Circle-Specific Guidance
</h3> </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>Curated resources for your stage</li>
<li>Connection with peers on similar journeys</li> <li>Connection with peers on similar journeys</li>
<li>Relevant workshop recommendations</li> <li>Relevant workshop recommendations</li>
@ -390,25 +373,23 @@
</section> </section>
<!-- How to Join --> <!-- How to Join -->
<section class="py-20 bg-white dark:bg-gray-900"> <section class="py-20 bg-[--ui-bg]">
<UContainer> <UContainer>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<div class="space-y-8"> <div class="space-y-8">
<div class="flex items-start gap-6"> <div class="flex items-start gap-6">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div <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 1
</div> </div>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h3 <h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
>
Pick your circle Pick your circle
</h3> </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 Where are you in your co-op journey? Select based on where you
are in your cooperative journey - exploring, building, or are in your cooperative journey - exploring, building, or
practicing. Not sure? Start with Community. practicing. Not sure? Start with Community.
@ -419,18 +400,16 @@
<div class="flex items-start gap-6"> <div class="flex items-start gap-6">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div <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 2
</div> </div>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h3 <h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
>
Choose your contribution Choose your contribution
</h3> </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 What can you afford? ($0-50+/month) Choose based on your
financial capacity. From $0 for those who need support to $50+ financial capacity. From $0 for those who need support to $50+
for those who can sponsor others. You can adjust anytime. for those who can sponsor others. You can adjust anytime.
@ -441,18 +420,16 @@
<div class="flex items-start gap-6"> <div class="flex items-start gap-6">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<div <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 3
</div> </div>
</div> </div>
<div class="flex-1"> <div class="flex-1">
<h3 <h3 class="text-xl font-semibold mb-2 text-[--ui-text]">
class="text-xl font-semibold mb-2 text-gray-900 dark:text-white"
>
Join the community Join the community
</h3> </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 Get instant access to everything. Fill out your profile, agree
to our community guidelines, and complete payment (if to our community guidelines, and complete payment (if
applicable). You'll get instant access to our community. applicable). You'll get instant access to our community.

View file

@ -1,7 +1,7 @@
<template> <template>
<div> <div>
<!-- Page Header --> <!-- Page Header -->
<PageHeader <PageHeader
title="Login" title="Login"
subtitle="Welcome back! Sign in to access your Ghost Guild account and connect with the cooperative community." subtitle="Welcome back! Sign in to access your Ghost Guild account and connect with the cooperative community."
theme="blue" theme="blue"
@ -9,18 +9,20 @@
/> />
<!-- Login Form --> <!-- Login Form -->
<section class="py-20 bg-white dark:bg-gray-900"> <section class="py-20 bg-[--ui-bg]">
<UContainer class="max-w-md"> <UContainer class="max-w-md">
<div class="text-center mb-12"> <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 Passwordless Login
</h2> </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 Enter your email to receive a secure login link
</p> </p>
</div> </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"> <UForm :state="loginForm" class="space-y-6" @submit="handleLogin">
<!-- Email Field --> <!-- Email Field -->
<UFormField label="Email Address" name="email" required> <UFormField label="Email Address" name="email" required>
@ -34,7 +36,7 @@
</UFormField> </UFormField>
<!-- Passwordless Info --> <!-- 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="flex items-start gap-3">
<div class="space-y-1 flex-shrink-0 mt-1"> <div class="space-y-1 flex-shrink-0 mt-1">
<div class="w-2 h-2 bg-blue-500 rounded-full" /> <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 class="h-1 bg-blue-200 rounded-full w-1/2" />
</div> </div>
</div> </div>
<p class="text-blue-700 dark:text-blue-300 text-sm mt-3"> <p class="text-primary-700 text-sm mt-3">
We'll send you a secure login link via email. No password needed! We'll send you a secure login link via email. No password
needed!
</p> </p>
</div> </div>
@ -66,23 +69,31 @@
</UForm> </UForm>
<!-- Success/Error Messages --> <!-- 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"> <div
<p class="text-green-700 dark:text-green-300 text-center"> v-if="loginSuccess"
Magic link sent! Check your email and click the link to sign in. 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> </p>
</div> </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"> <div
<p class="text-red-700 dark:text-red-300 text-center"> v-if="loginError"
{{ loginError }} class="mt-6 p-4 bg-red-50 rounded-lg border border-red-200"
</p> >
<p class="text-red-700 text-center"> {{ loginError }}</p>
</div> </div>
<!-- Sign Up Link --> <!-- Sign Up Link -->
<div class="mt-6 text-center"> <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? 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 Join Ghost Guild
</NuxtLink> </NuxtLink>
</p> </p>
@ -92,19 +103,25 @@
</section> </section>
<!-- Forgot Password --> <!-- 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"> <UContainer class="max-w-md">
<div class="text-center mb-12"> <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 Forgot Password
</h2> </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 Enter your email to receive a password reset link
</p> </p>
</div> </div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-8 shadow-xl border border-blue-200 dark:border-blue-800"> <div
<UForm :state="forgotPasswordForm" class="space-y-6" @submit="handleForgotPassword"> 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 --> <!-- Email Field -->
<UFormField label="Email Address" name="email" required> <UFormField label="Email Address" name="email" required>
<UInput <UInput
@ -117,7 +134,7 @@
</UFormField> </UFormField>
<!-- Reset Instructions --> <!-- 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="flex items-start gap-3">
<div class="space-y-1 flex-shrink-0 mt-1"> <div class="space-y-1 flex-shrink-0 mt-1">
<div class="w-2 h-2 bg-blue-500 rounded-full" /> <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 class="h-1 bg-blue-200 rounded-full w-1/2" />
</div> </div>
</div> </div>
<p class="text-blue-700 dark:text-blue-300 text-sm mt-3"> <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. We'll send you a secure link to reset your password. Check your
email inbox and spam folder.
</p> </p>
</div> </div>
@ -150,41 +168,44 @@
</UForm> </UForm>
<!-- Success/Error Messages --> <!-- 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"> <div
<p class="text-green-700 dark:text-green-300 text-center"> 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. Password reset link sent! Check your email.
</p> </p>
</div> </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"> <div
<p class="text-red-700 dark:text-red-300 text-center"> v-if="resetError"
{{ resetError }} class="mt-6 p-4 bg-red-50 rounded-lg border border-red-200"
</p> >
<p class="text-red-700 text-center"> {{ resetError }}</p>
</div> </div>
</div> </div>
</UContainer> </UContainer>
</section> </section>
<!-- Sign In CTA --> <!-- Sign In CTA -->
<section class="py-20 bg-white dark:bg-gray-900"> <section class="py-20 bg-[--ui-bg]">
<UContainer> <UContainer>
<div class="text-center max-w-2xl mx-auto"> <div class="text-center max-w-2xl 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">Sign In</h2>
Sign In
</h2>
<div class="space-y-4 mb-8"> <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-500 rounded-full w-64 mx-auto" />
<div class="h-2 bg-blue-300 rounded-full w-48 mx-auto" /> <div class="h-2 bg-blue-300 rounded-full w-48 mx-auto" />
</div> </div>
<p class="text-lg text-gray-600 dark:text-gray-300 mb-8"> <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? Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ready to
access your account and connect with the community?
</p> </p>
<UButton <UButton
@click="scrollToLoginForm" @click="scrollToLoginForm"
size="xl" size="xl"
color="primary" color="primary"
class="px-12" class="px-12"
> >
@ -195,13 +216,13 @@
</section> </section>
<!-- Access Your Dashboard --> <!-- Access Your Dashboard -->
<section class="py-20 bg-blue-50 dark:bg-blue-900/20"> <section class="py-20 bg-primary-50">
<UContainer> <UContainer>
<div class="text-center max-w-3xl mx-auto"> <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 Access Your Dashboard
</h2> </h2>
<div class="space-y-3 mb-8"> <div class="space-y-3 mb-8">
<div class="h-2 bg-blue-500 rounded-full w-full max-w-lg mx-auto" /> <div class="h-2 bg-blue-500 rounded-full w-full max-w-lg mx-auto" />
<div class="h-2 bg-blue-400 rounded-full w-full max-w-md mx-auto" /> <div class="h-2 bg-blue-400 rounded-full w-full max-w-md mx-auto" />
@ -209,53 +230,59 @@
<div class="h-2 bg-blue-200 rounded-full w-full max-w-xs mx-auto" /> <div class="h-2 bg-blue-200 rounded-full w-full max-w-xs mx-auto" />
</div> </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"> <div
<p class="text-lg text-gray-600 dark:text-gray-300 mb-6"> class="bg-[--ui-bg-elevated] rounded-2xl p-8 shadow-lg border border-primary-200 mb-8"
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Once you're logged in, you'll have access to: >
<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> </p>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 text-left"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6 text-left">
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-500 rounded-full" /> <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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-400 rounded-full" /> <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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-2 h-2 bg-blue-300 rounded-full" /> <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> </div>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-2 h-2 bg-emerald-500 rounded-full" /> <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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-2 h-2 bg-emerald-400 rounded-full" /> <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>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-2 h-2 bg-emerald-300 rounded-full" /> <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> </div>
</div> </div>
<div class="text-center"> <div class="text-center">
<p class="text-gray-600 dark:text-gray-300 mb-4"> <p class="text-[--ui-text-muted] mb-4">New to Ghost Guild?</p>
New to Ghost Guild? <UButton to="/join" variant="outline" size="lg" class="px-8">
</p>
<UButton
to="/join"
variant="outline"
size="lg"
class="px-8"
>
Create Your Account Create Your Account
</UButton> </UButton>
</div> </div>
@ -266,112 +293,112 @@
</template> </template>
<script setup> <script setup>
import { reactive, ref, computed } from 'vue' import { reactive, ref, computed } from "vue";
// Login form state // Login form state
const loginForm = reactive({ const loginForm = reactive({
email: '' email: "",
}) });
// Forgot password form state // Forgot password form state
const forgotPasswordForm = reactive({ const forgotPasswordForm = reactive({
email: '' email: "",
}) });
// UI state // UI state
const isLoggingIn = ref(false) const isLoggingIn = ref(false);
const isResettingPassword = ref(false) const isResettingPassword = ref(false);
const loginSuccess = ref(false) const loginSuccess = ref(false);
const loginError = ref('') const loginError = ref("");
const resetSuccess = ref(false) const resetSuccess = ref(false);
const resetError = ref('') const resetError = ref("");
// Form validation // Form validation
const isLoginFormValid = computed(() => { const isLoginFormValid = computed(() => {
return loginForm.email && loginForm.email.includes('@') return loginForm.email && loginForm.email.includes("@");
}) });
// Login handler // Login handler
const handleLogin = async () => { const handleLogin = async () => {
if (isLoggingIn.value) return if (isLoggingIn.value) return;
isLoggingIn.value = true isLoggingIn.value = true;
loginError.value = '' loginError.value = "";
loginSuccess.value = false loginSuccess.value = false;
try { try {
// Call the passwordless login API // Call the passwordless login API
const response = await $fetch('/api/auth/login', { const response = await $fetch("/api/auth/login", {
method: 'POST', method: "POST",
body: { body: {
email: loginForm.email email: loginForm.email,
} },
}) });
if (response.success) { if (response.success) {
loginSuccess.value = true loginSuccess.value = true;
loginError.value = '' loginError.value = "";
// Clear the form // Clear the form
loginForm.email = '' loginForm.email = "";
} }
} catch (err) { } catch (err) {
console.error('Login error:', err) console.error("Login error:", err);
// Handle different error types // Handle different error types
if (err.statusCode === 404) { 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) { } 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 { } else {
loginError.value = err.statusMessage || 'Something went wrong. Please try again.' loginError.value =
err.statusMessage || "Something went wrong. Please try again.";
} }
} finally { } finally {
isLoggingIn.value = false isLoggingIn.value = false;
} }
} };
// Forgot password handler // Forgot password handler
const handleForgotPassword = async () => { const handleForgotPassword = async () => {
if (isResettingPassword.value) return if (isResettingPassword.value) return;
isResettingPassword.value = true isResettingPassword.value = true;
resetError.value = '' resetError.value = "";
resetSuccess.value = false resetSuccess.value = false;
try { try {
// Simulate API call // 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 // Reset form after success
setTimeout(() => { setTimeout(() => {
forgotPasswordForm.email = '' forgotPasswordForm.email = "";
resetSuccess.value = false resetSuccess.value = false;
}, 5000) }, 5000);
} catch (err) { } catch (err) {
console.error('Password reset error:', err) console.error("Password reset error:", err);
resetError.value = 'Failed to send reset email. Please try again.' resetError.value = "Failed to send reset email. Please try again.";
} finally { } finally {
isResettingPassword.value = false isResettingPassword.value = false;
} }
} };
// Scroll functions // Scroll functions
const scrollToLoginForm = () => { const scrollToLoginForm = () => {
const formSection = document.querySelector('form') const formSection = document.querySelector("form");
if (formSection) { if (formSection) {
formSection.scrollIntoView({ behavior: 'smooth', block: 'center' }) formSection.scrollIntoView({ behavior: "smooth", block: "center" });
} }
} };
const scrollToForgotPassword = () => { const scrollToForgotPassword = () => {
const forgotSection = document.getElementById('forgot-password') const forgotSection = document.getElementById("forgot-password");
if (forgotSection) { if (forgotSection) {
forgotSection.scrollIntoView({ behavior: 'smooth', block: 'start' }) forgotSection.scrollIntoView({ behavior: "smooth", block: "start" });
} }
} };
</script> </script>

View file

@ -18,7 +18,7 @@
<div <div
class="w-8 h-8 border-4 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" class="w-8 h-8 border-4 border-whisper-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div> ></div>
<p class="text-stone-300">Loading your dashboard...</p> <p class="text-ghost-300">Loading your dashboard...</p>
</div> </div>
</div> </div>
@ -36,10 +36,10 @@
<template #header> <template #header>
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="flex-1"> <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 }}! Welcome to Ghost Guild, {{ memberData?.name }}!
</h1> </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 Your membership is active and you're part of our cooperative
community. community.
</p> </p>
@ -53,7 +53,7 @@
</div> </div>
<div v-else class="flex-shrink-0"> <div v-else class="flex-shrink-0">
<div <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() }} {{ memberData?.name?.charAt(0)?.toUpperCase() }}
</div> </div>
@ -63,13 +63,13 @@
<div class="flex flex-wrap gap-4 text-sm"> <div class="flex flex-wrap gap-4 text-sm">
<div class="bg-ghost-800 border border-ghost-600 px-4 py-2"> <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">{{ <span class="font-medium text-whisper-300 ml-1 capitalize">{{
memberData?.circle memberData?.circle
}}</span> }}</span>
</div> </div>
<div class="bg-ghost-800 border border-ghost-600 px-4 py-2"> <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" <span class="font-medium text-whisper-300 ml-1"
>${{ memberData?.contributionTier }} CAD/month</span >${{ memberData?.contributionTier }} CAD/month</span
> >
@ -86,28 +86,16 @@
}" }"
> >
<template #header> <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 Quick Links
</h2> </h2>
</template> </template>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> <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 <UButton
disabled disabled
variant="outline" 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 block
title="Coming soon" title="Coming soon"
> >
@ -120,7 +108,7 @@
<UButton <UButton
disabled disabled
variant="outline" 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 block
title="Coming soon" title="Coming soon"
> >
@ -133,7 +121,7 @@
<UButton <UButton
disabled disabled
variant="outline" 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 block
title="Coming soon" title="Coming soon"
> >
@ -146,7 +134,7 @@
<UButton <UButton
to="/member/profile" to="/member/profile"
variant="outline" 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 block
> >
<template #leading> <template #leading>
@ -179,10 +167,10 @@
</div> </div>
</template> </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 Upcoming Events
</h3> </h3>
<p class="text-stone-300 mb-4"> <p class="text-ghost-300 mb-4">
Discover and register for community events and workshops. Discover and register for community events and workshops.
</p> </p>
@ -191,7 +179,7 @@
to="/events" to="/events"
variant="outline" variant="outline"
size="sm" 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 View Events
</UButton> </UButton>
@ -217,8 +205,8 @@
</div> </div>
</template> </template>
<h3 class="text-lg font-semibold mb-2 text-stone-100">Community</h3> <h3 class="text-lg font-semibold mb-2 text-ghost-100">Community</h3>
<p class="text-stone-300 mb-4"> <p class="text-ghost-300 mb-4">
Connect with other members in your circle and beyond. Connect with other members in your circle and beyond.
</p> </p>
@ -227,7 +215,7 @@
to="/members" to="/members"
variant="outline" variant="outline"
size="sm" 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 Browse Members
</UButton> </UButton>
@ -253,10 +241,10 @@
</div> </div>
</template> </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 Account Settings
</h3> </h3>
<p class="text-stone-300 mb-4"> <p class="text-ghost-300 mb-4">
Manage your profile and membership settings. Manage your profile and membership settings.
</p> </p>
@ -265,7 +253,7 @@
to="/member/profile#account" to="/member/profile#account"
variant="outline" variant="outline"
size="sm" 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 Manage Account
</UButton> </UButton>
@ -283,14 +271,14 @@
> >
<template #header> <template #header>
<div class="flex items-center justify-between"> <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 Your Upcoming Events
</h2> </h2>
<UButton <UButton
to="/events" to="/events"
variant="ghost" variant="ghost"
size="sm" size="sm"
class="text-stone-300 hover:text-stone-100" class="text-ghost-300 hover:text-ghost-100"
> >
Browse All Events Browse All Events
</UButton> </UButton>
@ -334,10 +322,10 @@
/> />
</div> </div>
<div class="flex-1 min-w-0"> <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 }} {{ evt.title }}
</h3> </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"> <span class="flex items-center gap-1">
<Icon name="heroicons:calendar" class="w-4 h-4" /> <Icon name="heroicons:calendar" class="w-4 h-4" />
{{ formatEventDate(evt.startDate) }} {{ formatEventDate(evt.startDate) }}
@ -351,7 +339,7 @@
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<Icon <Icon
name="heroicons:chevron-right" name="heroicons:chevron-right"
class="w-5 h-5 text-stone-500" class="w-5 h-5 text-ghost-500"
/> />
</div> </div>
</div> </div>
@ -361,108 +349,20 @@
<div v-else class="text-center py-8"> <div v-else class="text-center py-8">
<Icon <Icon
name="heroicons:calendar-days" 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 You haven't registered for any upcoming events
</p> </p>
<UButton <UButton
to="/events" to="/events"
size="sm" 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 Browse Events
</UButton> </UButton>
</div> </div>
</UCard> </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> </div>
</UContainer> </UContainer>
</div> </div>
@ -471,8 +371,6 @@
<script setup> <script setup>
const { memberData, checkMemberStatus } = useAuth(); const { memberData, checkMemberStatus } = useAuth();
const recentUpdates = ref([]);
const loadingUpdates = ref(false);
const registeredEvents = ref([]); const registeredEvents = ref([]);
const loadingEvents = ref(false); 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 // Load registered events
const loadRegisteredEvents = async () => { const loadRegisteredEvents = async () => {
console.log( console.log(
@ -575,23 +458,6 @@ const capitalize = (str) => {
.join("-"); .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 // Helper functions for event display
const getEventImageUrl = (featureImage) => { const getEventImageUrl = (featureImage) => {
if (!featureImage) return ""; if (!featureImage) return "";
@ -626,7 +492,6 @@ const formatEventTime = (dateString) => {
}; };
onMounted(() => { onMounted(() => {
loadRecentUpdates();
loadRegisteredEvents(); loadRegisteredEvents();
}); });

View file

@ -11,8 +11,8 @@
<UContainer class="px-4"> <UContainer class="px-4">
<!-- Stats --> <!-- Stats -->
<div v-if="!pending" class="mb-8 flex items-center justify-between"> <div v-if="!pending" class="mb-8 flex items-center justify-between">
<div class="text-stone-300"> <div class="text-ghost-300">
<span class="text-2xl font-bold text-stone-100">{{ total }}</span> <span class="text-2xl font-bold text-ghost-100">{{ total }}</span>
{{ total === 1 ? "update" : "updates" }} posted {{ total === 1 ? "update" : "updates" }} posted
</div> </div>
<UButton to="/updates/new" icon="i-lucide-plus"> New Update </UButton> <UButton to="/updates/new" icon="i-lucide-plus"> New Update </UButton>
@ -25,9 +25,9 @@
> >
<div class="text-center"> <div class="text-center">
<div <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> ></div>
<p class="text-stone-400">Loading your updates...</p> <p class="text-ghost-400">Loading your updates...</p>
</div> </div>
</div> </div>
@ -62,7 +62,7 @@
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
class="text-stone-600" class="text-ghost-600"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@ -72,10 +72,10 @@
/> />
</svg> </svg>
</div> </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 No updates yet
</h3> </h3>
<p class="text-stone-400 mb-6"> <p class="text-ghost-400 mb-6">
Share your first update with the community Share your first update with the community
</p> </p>
<UButton to="/updates/new" icon="i-lucide-plus"> <UButton to="/updates/new" icon="i-lucide-plus">

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,7 @@
<UContainer class="px-4"> <UContainer class="px-4">
<!-- Search and Filters --> <!-- Search and Filters -->
<div class="mb-8 space-y-4"> <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 --> <!-- Search -->
<div class="md:col-span-2"> <div class="md:col-span-2">
<UInput <UInput
@ -26,21 +26,28 @@
<!-- Circle Filter --> <!-- Circle Filter -->
<USelect <USelect
v-model="selectedCircle" v-model="selectedCircle"
:options="circleOptions" :items="circleOptions"
placeholder="All Circles"
size="lg" size="lg"
@change="loadMembers" @update:model-value="loadMembers"
/>
<!-- Peer Support Filter -->
<USelect
v-model="peerSupportFilter"
:items="peerSupportOptions"
size="lg"
@update:model-value="loadMembers"
/> />
</div> </div>
<!-- Skills Filter --> <!-- Skills Filter -->
<div v-if="availableSkills.length > 0"> <div v-if="availableSkills && availableSkills.length > 0">
<div class="flex flex-wrap gap-2"> <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 >Filter by skill:</span
> >
<button <button
v-for="skill in availableSkills.slice( v-for="skill in (availableSkills || []).slice(
0, 0,
showAllSkills ? undefined : 10, showAllSkills ? undefined : 10,
)" )"
@ -50,14 +57,14 @@
:class=" :class="
selectedSkills.includes(skill) selectedSkills.includes(skill)
? 'bg-purple-500/20 text-purple-300 border-purple-500/50' ? '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)" @click="toggleSkill(skill)"
> >
{{ skill }} {{ skill }}
</button> </button>
<button <button
v-if="availableSkills.length > 10" v-if="availableSkills && availableSkills.length > 10"
type="button" type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300" class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
@click="showAllSkills = !showAllSkills" @click="showAllSkills = !showAllSkills"
@ -71,12 +78,55 @@
</div> </div>
</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 --> <!-- Active Filters -->
<div <div
v-if="selectedCircle || selectedSkills.length > 0" v-if="
class="flex items-center gap-2 text-sm" 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 <span
v-if="selectedCircle" 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" 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> </button>
</span> </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
type="button"
class="hover:text-purple-200"
@click="clearPeerSupportFilter"
>
×
</button>
</span>
<button <button
v-if="selectedSkills.length > 0" v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
type="button" type="button"
class="text-purple-400 hover:text-purple-300" class="text-purple-400 hover:text-purple-300"
@click="clearAllFilters" @click="clearAllFilters"
@ -110,138 +173,282 @@
<div <div
class="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4" class="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-4"
></div> ></div>
<p class="text-stone-400">Loading members...</p> <p class="text-ghost-400">Loading members...</p>
</div> </div>
</div> </div>
<!-- Members List --> <!-- Members List -->
<div v-else-if="members.length > 0"> <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 {{ totalCount }} {{ totalCount === 1 ? "member" : "members" }} found
</div> </div>
<div class="space-y-2"> <div class="space-y-4">
<div <div
v-for="member in members" v-for="member in members"
:key="member._id" :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"
> >
<!-- Avatar --> <!-- Header Section -->
<div <div class="flex items-start gap-4 mb-4">
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" <!-- Avatar -->
> <div
<img 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"
v-if="member.avatar" >
:src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`" <img
:alt="member.name" v-if="member.avatar"
class="w-8 h-8 object-contain" :src="`/ghosties/Ghost-${member.avatar.charAt(0).toUpperCase() + member.avatar.slice(1)}.png`"
/> :alt="member.name"
<span v-else class="text-xl text-stone-600">👻</span> class="w-12 h-12 object-contain"
</div> />
<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-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 <NuxtLink
:to="`/updates/user/${member._id}`" :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-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-ghost-400">
{{ member.studio }}
</span>
<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>
<!-- Social Links -->
<div
v-if="
member.socialLinks && hasSocialLinks(member.socialLinks)
"
class="flex gap-3"
> >
{{ member.name }} <a
</NuxtLink> v-if="member.socialLinks.mastodon"
<span v-if="member.pronouns" class="text-sm text-stone-400"> :href="member.socialLinks.mastodon"
{{ member.pronouns }} target="_blank"
</span> rel="noopener noreferrer"
<span class="text-ghost-400 hover:text-purple-400 transition-colors"
class="px-2 py-0.5 bg-purple-500/20 text-purple-300 rounded text-xs border border-purple-500/30" title="Mastodon"
> >
{{ circleLabels[member.circle] }} <svg
</span> class="w-5 h-5"
<span v-if="member.studio" class="text-sm text-stone-400"> fill="currentColor"
{{ member.studio }} viewBox="0 0 24 24"
</span> >
<span v-if="member.location" class="text-sm text-stone-500"> <path
{{ member.location }} 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"
</span> />
</svg>
</a>
<a
v-if="member.socialLinks.linkedin"
:href="member.socialLinks.linkedin"
target="_blank"
rel="noopener noreferrer"
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"
>
<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"
/>
</svg>
</a>
<a
v-if="member.socialLinks.website"
:href="member.socialLinks.website"
target="_blank"
rel="noopener noreferrer"
class="text-ghost-400 hover:text-purple-400 transition-colors"
title="Website"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
</a>
<a
v-if="member.socialLinks.other"
:href="member.socialLinks.other"
target="_blank"
rel="noopener noreferrer"
class="text-ghost-400 hover:text-purple-400 transition-colors"
title="Other link"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
/>
</svg>
</a>
</div>
</div> </div>
</div> </div>
<!-- Social Links --> <!-- 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 <div
v-if="member.socialLinks && hasSocialLinks(member.socialLinks)" v-if="member.peerSupport?.enabled"
class="flex gap-3 flex-shrink-0" class="mb-4 p-4 bg-purple-500/10 border border-purple-500/30 rounded-lg"
> >
<a <div class="flex items-center gap-2 mb-2">
v-if="member.socialLinks.mastodon" <span class="text-purple-300 font-medium text-sm">
:href="member.socialLinks.mastodon" 💜 Offering Peer Support
target="_blank" </span>
rel="noopener noreferrer" </div>
class="text-stone-400 hover:text-purple-400 transition-colors"
title="Mastodon" <!-- Topics -->
<div
v-if="
member.peerSupport.topics &&
member.peerSupport.topics.length > 0
"
class="mb-2"
> >
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <div class="flex flex-wrap gap-1">
<path <span
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" v-for="topic in member.peerSupport.topics"
/> :key="topic"
</svg> 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> </a>
<a </div>
v-if="member.socialLinks.linkedin"
:href="member.socialLinks.linkedin" <!-- Offering and Looking For -->
target="_blank" <div
rel="noopener noreferrer" v-if="member.offering || member.lookingFor"
class="text-stone-400 hover:text-purple-400 transition-colors" class="grid grid-cols-1 md:grid-cols-2 gap-4"
title="LinkedIn" >
> <!-- Offering -->
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"> <div v-if="member.offering" class="space-y-2">
<path <h4 class="text-xs font-semibold text-purple-400 uppercase">
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" Offering
/> </h4>
</svg> <p
</a> v-if="member.offering.description"
<a class="text-ghost-300 text-sm"
v-if="member.socialLinks.website"
:href="member.socialLinks.website"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
title="Website"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path {{ member.offering.description }}
stroke-linecap="round" </p>
stroke-linejoin="round" <div
stroke-width="2" v-if="
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" member.offering.tags && member.offering.tags.length > 0
/> "
</svg> class="flex flex-wrap gap-1"
</a>
<a
v-if="member.socialLinks.other"
:href="member.socialLinks.other"
target="_blank"
rel="noopener noreferrer"
class="text-stone-400 hover:text-purple-400 transition-colors"
title="Other link"
>
<svg
class="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
> >
<path <span
stroke-linecap="round" v-for="tag in member.offering.tags"
stroke-linejoin="round" :key="tag"
stroke-width="2" class="px-2 py-0.5 bg-green-500/20 text-green-300 rounded text-xs border border-green-500/30"
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" >
/> {{ tag }}
</svg> </span>
</a> </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>
</div> </div>
@ -254,7 +461,7 @@
fill="none" fill="none"
stroke="currentColor" stroke="currentColor"
viewBox="0 0 24 24" viewBox="0 0 24 24"
class="text-stone-600" class="text-ghost-600"
> >
<path <path
stroke-linecap="round" stroke-linecap="round"
@ -264,10 +471,10 @@
/> />
</svg> </svg>
</div> </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 No members found
</h3> </h3>
<p class="text-stone-400 mb-6"> <p class="text-ghost-400 mb-6">
Try adjusting your search or filters Try adjusting your search or filters
</p> </p>
<UButton variant="outline" @click="clearAllFilters"> <UButton variant="outline" @click="clearAllFilters">
@ -300,15 +507,19 @@ const { isAuthenticated } = useAuth();
const members = ref([]); const members = ref([]);
const totalCount = ref(0); const totalCount = ref(0);
const availableSkills = ref([]); const availableSkills = ref([]);
const loading = ref(false); const availableTopics = ref([]);
const loading = ref(true); // Start with loading true
const searchQuery = ref(""); const searchQuery = ref("");
const selectedCircle = ref(""); const selectedCircle = ref("all");
const peerSupportFilter = ref("all");
const selectedSkills = ref([]); const selectedSkills = ref([]);
const selectedTopics = ref([]);
const showAllSkills = ref(false); const showAllSkills = ref(false);
const showAllTopics = ref(false);
// Circle options // Circle options
const circleOptions = [ const circleOptions = [
{ label: "All Circles", value: "" }, { label: "All Circles", value: "all" },
{ label: "Community", value: "community" }, { label: "Community", value: "community" },
{ label: "Founder", value: "founder" }, { label: "Founder", value: "founder" },
{ label: "Practitioner", value: "practitioner" }, { label: "Practitioner", value: "practitioner" },
@ -320,6 +531,12 @@ const circleLabels = {
practitioner: "Practitioner", 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 // Helper to check if member has social links
const hasSocialLinks = (links) => { const hasSocialLinks = (links) => {
if (!links) return false; if (!links) return false;
@ -333,17 +550,27 @@ const loadMembers = async () => {
try { try {
const params = {}; const params = {};
if (searchQuery.value) params.search = searchQuery.value; 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) if (selectedSkills.value.length > 0)
params.skills = selectedSkills.value.join(","); params.skills = selectedSkills.value.join(",");
if (selectedTopics.value.length > 0)
params.topics = selectedTopics.value.join(",");
const data = await $fetch("/api/members/directory", { params }); const data = await $fetch("/api/members/directory", { params });
members.value = data.members; members.value = data.members || [];
totalCount.value = data.totalCount; totalCount.value = data.totalCount || 0;
availableSkills.value = data.filters.availableSkills; availableSkills.value = data.filters?.availableSkills || [];
availableTopics.value = data.filters?.availableTopics || [];
} catch (error) { } catch (error) {
console.error("Failed to load members:", error); console.error("Failed to load members:", error);
members.value = [];
totalCount.value = 0;
availableSkills.value = [];
availableTopics.value = [];
} finally { } finally {
loading.value = false; loading.value = false;
} }
@ -369,19 +596,53 @@ const toggleSkill = (skill) => {
loadMembers(); 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 // Clear filters
const clearCircleFilter = () => { const clearCircleFilter = () => {
selectedCircle.value = ""; selectedCircle.value = "all";
loadMembers(); };
const clearPeerSupportFilter = () => {
peerSupportFilter.value = "all";
}; };
const clearAllFilters = () => { const clearAllFilters = () => {
searchQuery.value = ""; searchQuery.value = "";
selectedCircle.value = ""; selectedCircle.value = "all";
peerSupportFilter.value = "all";
selectedSkills.value = []; selectedSkills.value = [];
selectedTopics.value = [];
loadMembers(); 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 // Load on mount
onMounted(() => { onMounted(() => {
loadMembers(); loadMembers();

View 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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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);
});

View file

@ -41,13 +41,14 @@ export default defineEventHandler(async (event) => {
avatar: member.avatar, avatar: member.avatar,
studio: member.studio, studio: member.studio,
bio: member.bio, bio: member.bio,
skills: member.skills,
location: member.location, location: member.location,
socialLinks: member.socialLinks, socialLinks: member.socialLinks,
offering: member.offering, offering: member.offering,
lookingFor: member.lookingFor, lookingFor: member.lookingFor,
showInDirectory: member.showInDirectory, showInDirectory: member.showInDirectory,
privacy: member.privacy, privacy: member.privacy,
// Peer support
peerSupport: member.peerSupport,
}; };
} catch (err) { } catch (err) {
console.error("Token verification error:", err); console.error("Token verification error:", err);

View file

@ -24,7 +24,9 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event); const query = getQuery(event);
const search = query.search || ""; const search = query.search || "";
const circle = query.circle || ""; 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 // Build query
const dbQuery = { const dbQuery = {
@ -37,6 +39,11 @@ export default defineEventHandler(async (event) => {
dbQuery.circle = circle; dbQuery.circle = circle;
} }
// Filter by peer support availability
if (peerSupport === "true") {
dbQuery["peerSupport.enabled"] = true;
}
// Search by name or bio // Search by name or bio
if (search) { if (search) {
dbQuery.$or = [ dbQuery.$or = [
@ -45,15 +52,41 @@ export default defineEventHandler(async (event) => {
]; ];
} }
// Filter by skills // Filter by tags (search in offering.tags or lookingFor.tags)
if (skills.length > 0) { if (tags.length > 0) {
dbQuery.skills = { $in: skills }; 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 { try {
const members = await Member.find(dbQuery) const members = await Member.find(dbQuery)
.select( .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 }) .sort({ createdAt: -1 })
.lean(); .lean();
@ -83,26 +116,42 @@ export default defineEventHandler(async (event) => {
if (isVisible("timeZone")) filtered.timeZone = member.timeZone; if (isVisible("timeZone")) filtered.timeZone = member.timeZone;
if (isVisible("studio")) filtered.studio = member.studio; if (isVisible("studio")) filtered.studio = member.studio;
if (isVisible("bio")) filtered.bio = member.bio; if (isVisible("bio")) filtered.bio = member.bio;
if (isVisible("skills")) filtered.skills = member.skills;
if (isVisible("location")) filtered.location = member.location; if (isVisible("location")) filtered.location = member.location;
if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks; if (isVisible("socialLinks")) filtered.socialLinks = member.socialLinks;
if (isVisible("offering")) filtered.offering = member.offering; if (isVisible("offering")) filtered.offering = member.offering;
if (isVisible("lookingFor")) filtered.lookingFor = member.lookingFor; 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; return filtered;
}); });
// Get unique skills for filter options // Get unique tags for filter options (from both offering and lookingFor)
const allSkills = members const allTags = members
.flatMap((m) => m.skills || []) .flatMap((m) => [
.filter((skill, index, self) => self.indexOf(skill) === index) ...(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(); .sort();
return { return {
members: filteredMembers, members: filteredMembers,
totalCount: filteredMembers.length, totalCount: filteredMembers.length,
filters: { filters: {
availableSkills: allSkills, availableSkills: allTags,
availableTopics: allTopics,
}, },
}; };
} catch (error) { } catch (error) {

View 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",
});
}
});

View file

@ -34,11 +34,8 @@ export default defineEventHandler(async (event) => {
"avatar", "avatar",
"studio", "studio",
"bio", "bio",
"skills",
"location", "location",
"socialLinks", "socialLinks",
"offering",
"lookingFor",
"showInDirectory", "showInDirectory",
"helcimCustomerId", "helcimCustomerId",
]; ];
@ -50,7 +47,6 @@ export default defineEventHandler(async (event) => {
"avatarPrivacy", "avatarPrivacy",
"studioPrivacy", "studioPrivacy",
"bioPrivacy", "bioPrivacy",
"skillsPrivacy",
"locationPrivacy", "locationPrivacy",
"socialLinksPrivacy", "socialLinksPrivacy",
"offeringPrivacy", "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 // Handle privacy settings
privacyFields.forEach((privacyField) => { privacyFields.forEach((privacyField) => {
if (body[privacyField] !== undefined) { if (body[privacyField] !== undefined) {
@ -100,7 +110,6 @@ export default defineEventHandler(async (event) => {
avatar: member.avatar, avatar: member.avatar,
studio: member.studio, studio: member.studio,
bio: member.bio, bio: member.bio,
skills: member.skills,
location: member.location, location: member.location,
socialLinks: member.socialLinks, socialLinks: member.socialLinks,
offering: member.offering, offering: member.offering,

View 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",
});
}
});

View 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,
};
});

View 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();

View file

@ -51,7 +51,6 @@ const memberSchema = new mongoose.Schema({
avatar: String, avatar: String,
studio: String, studio: String,
bio: String, bio: String,
skills: [String],
location: String, location: String,
socialLinks: { socialLinks: {
mastodon: String, mastodon: String,
@ -59,10 +58,27 @@ const memberSchema = new mongoose.Schema({
website: String, website: String,
other: String, other: String,
}, },
offering: String, offering: {
lookingFor: String, text: String,
tags: [String],
},
lookingFor: {
text: String,
tags: [String],
},
showInDirectory: { type: Boolean, default: true }, 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 settings for profile fields
privacy: { privacy: {
pronouns: { pronouns: {
@ -90,11 +106,6 @@ const memberSchema = new mongoose.Schema({
enum: ["public", "members", "private"], enum: ["public", "members", "private"],
default: "members", default: "members",
}, },
skills: {
type: String,
enum: ["public", "members", "private"],
default: "members",
},
location: { location: {
type: String, type: String,
enum: ["public", "members", "private"], enum: ["public", "members", "private"],

View file

@ -14,7 +14,7 @@ export class SlackService {
*/ */
async inviteUserToSlack( async inviteUserToSlack(
email: string, email: string,
realName: string realName: string,
): Promise<{ ): Promise<{
success: boolean; success: boolean;
userId?: string; userId?: string;
@ -34,7 +34,7 @@ export class SlackService {
}); });
console.log( console.log(
`Successfully invited existing user ${email} to vetting channel` `Successfully invited existing user ${email} to vetting channel`,
); );
return { return {
success: true, success: true,
@ -65,7 +65,7 @@ export class SlackService {
if (inviteResponse.ok && inviteResponse.user) { if (inviteResponse.ok && inviteResponse.user) {
console.log( console.log(
`Successfully invited ${email} to workspace as single-channel guest` `Successfully invited ${email} to workspace as single-channel guest`,
); );
return { return {
success: true, success: true,
@ -79,7 +79,7 @@ export class SlackService {
console.log( console.log(
`Admin API not available or failed: ${ `Admin API not available or failed: ${
adminError.data?.error || adminError.message adminError.data?.error || adminError.message
}` }`,
); );
// Fall back to manual process // 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 * Send a notification to the vetting channel about a new member
*/ */
@ -121,7 +182,7 @@ export class SlackService {
memberEmail: string, memberEmail: string,
circle: string, circle: string,
contributionTier: string, contributionTier: string,
invitationStatus: string = "manual_invitation_required" invitationStatus: string = "manual_invitation_required",
): Promise<void> { ): Promise<void> {
try { try {
let statusMessage = ""; let statusMessage = "";
@ -224,7 +285,7 @@ export function getSlackService(): SlackService | null {
if (!config.slackBotToken || !config.slackVettingChannelId) { if (!config.slackBotToken || !config.slackVettingChannelId) {
console.warn( console.warn(
"Slack integration not configured - missing bot token or channel ID" "Slack integration not configured - missing bot token or channel ID",
); );
return null; return null;
} }