Lots of UI fixes

This commit is contained in:
Jennie Robinson Faber 2025-10-08 19:02:24 +01:00
parent 1f7a0f40c0
commit e8e3b84276
24 changed files with 3652 additions and 1770 deletions

View file

@ -42,7 +42,7 @@
<button
type="button"
@click="$refs.fileInput.click()"
class="text-blue-600 hover:text-blue-500 font-medium"
class="text-primary-600 hover:text-primary-500 font-medium"
>
Click to upload
</button>

View file

@ -1,68 +1,40 @@
// Central configuration for Ghost Guild Contribution Levels and Helcim Plans
export const CONTRIBUTION_TIERS = {
FREE: {
value: '0',
value: "0",
amount: 0,
label: '$0 - I need support right now',
tier: 'free',
label: "$0 - I need support right now",
tier: "free",
helcimPlanId: null, // No Helcim plan needed for free tier
features: [
'Access to basic resources',
'Community forum access'
]
},
SUPPORTER: {
value: '5',
value: "5",
amount: 5,
label: '$5 - I can contribute a little',
tier: 'supporter',
helcimPlanId: 'supporter-monthly-5',
features: [
'All Free Membership benefits',
'Priority community support',
'Early access to events'
]
label: "$5 - I can contribute",
tier: "supporter",
helcimPlanId: "supporter-monthly-5",
},
MEMBER: {
value: '15',
value: "15",
amount: 15,
label: '$15 - I can sustain the community',
tier: 'member',
helcimPlanId: 'member-monthly-15',
features: [
'All Supporter benefits',
'Access to premium workshops',
'Monthly 1-on-1 sessions',
'Advanced resource library'
]
label: "$15 - I can sustain the community",
tier: "member",
helcimPlanId: "member-monthly-15",
},
ADVOCATE: {
value: '30',
value: "30",
amount: 30,
label: '$30 - I can support others too',
tier: 'advocate',
helcimPlanId: 'advocate-monthly-30',
features: [
'All Member benefits',
'Weekly group mentoring',
'Access to exclusive events',
'Direct messaging with experts'
]
label: "$30 - I can support others too",
tier: "advocate",
helcimPlanId: "advocate-monthly-30",
},
CHAMPION: {
value: '50',
value: "50",
amount: 50,
label: '$50 - I want to sponsor multiple members',
tier: 'champion',
helcimPlanId: 'champion-monthly-50',
features: [
'All Advocate benefits',
'Personal mentoring sessions',
'VIP event access',
'Custom project support',
'Annual strategy session'
]
}
label: "$50 - I want to sponsor multiple members",
tier: "champion",
helcimPlanId: "champion-monthly-50",
},
};
// Get all contribution options as an array (useful for forms)
@ -72,12 +44,12 @@ export const getContributionOptions = () => {
// Get valid contribution values for validation
export const getValidContributionValues = () => {
return Object.values(CONTRIBUTION_TIERS).map(tier => tier.value);
return Object.values(CONTRIBUTION_TIERS).map((tier) => tier.value);
};
// Get contribution tier by value
export const getContributionTierByValue = (value) => {
return Object.values(CONTRIBUTION_TIERS).find(tier => tier.value === value);
return Object.values(CONTRIBUTION_TIERS).find((tier) => tier.value === value);
};
// Get Helcim plan ID for a contribution tier
@ -99,10 +71,12 @@ export const isValidContributionValue = (value) => {
// Get contribution tier by Helcim plan ID
export const getContributionTierByHelcimPlan = (helcimPlanId) => {
return Object.values(CONTRIBUTION_TIERS).find(tier => tier.helcimPlanId === helcimPlanId);
return Object.values(CONTRIBUTION_TIERS).find(
(tier) => tier.helcimPlanId === helcimPlanId,
);
};
// Get paid tiers only (excluding free tier)
export const getPaidContributionTiers = () => {
return Object.values(CONTRIBUTION_TIERS).filter(tier => tier.amount > 0);
return Object.values(CONTRIBUTION_TIERS).filter((tier) => tier.amount > 0);
};

View file

@ -5,7 +5,10 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center py-4">
<div class="flex items-center gap-8">
<NuxtLink to="/" class="text-xl font-bold text-gray-900 hover:text-blue-600">
<NuxtLink
to="/"
class="text-xl font-bold text-gray-900 hover:text-primary"
>
Ghost Guild
</NuxtLink>
@ -15,13 +18,28 @@
:class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path === '/admin'
? 'bg-blue-100 text-blue-700 shadow-sm'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
? 'bg-primary-100 text-primary-700 shadow-sm'
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
]"
>
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4h4"/>
<svg
class="w-4 h-4 inline-block mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2 2z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4h4"
/>
</svg>
Dashboard
</NuxtLink>
@ -31,12 +49,22 @@
:class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path.includes('/admin/members')
? 'bg-blue-100 text-blue-700 shadow-sm'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
? 'bg-primary-100 text-primary-700 shadow-sm'
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
]"
>
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
<svg
class="w-4 h-4 inline-block mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
Members
</NuxtLink>
@ -46,12 +74,22 @@
:class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path.includes('/admin/events')
? 'bg-blue-100 text-blue-700 shadow-sm'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
? 'bg-primary-100 text-primary-700 shadow-sm'
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
]"
>
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
<svg
class="w-4 h-4 inline-block mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
/>
</svg>
Events
</NuxtLink>
@ -61,12 +99,22 @@
:class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path.includes('/admin/series')
? 'bg-blue-100 text-blue-700 shadow-sm'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
? 'bg-primary-100 text-primary-700 shadow-sm'
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
]"
>
<svg class="w-4 h-4 inline-block mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
<svg
class="w-4 h-4 inline-block mr-2"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
Series
</NuxtLink>
@ -75,39 +123,121 @@
<div class="flex items-center gap-4">
<!-- User Menu -->
<div class="relative" @click="showUserMenu = !showUserMenu" v-click-outside="() => showUserMenu = false">
<button class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors">
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/>
<div
class="relative"
@click="showUserMenu = !showUserMenu"
v-click-outside="() => (showUserMenu = false)"
>
<button
class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors"
>
<div
class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center"
>
<svg
class="w-4 h-4 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
</div>
<span class="hidden md:block text-sm font-medium text-gray-700">Admin</span>
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
<span class="hidden md:block text-sm font-medium text-gray-700"
>Admin</span
>
<svg
class="w-4 h-4 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
<!-- User Menu Dropdown -->
<div v-if="showUserMenu" class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50">
<NuxtLink to="/" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
<div
v-if="showUserMenu"
class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50"
>
<NuxtLink
to="/"
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<svg
class="w-4 h-4 mr-3 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
/>
</svg>
View Site
</NuxtLink>
<NuxtLink to="/admin/settings" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<NuxtLink
to="/admin/settings"
class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
<svg
class="w-4 h-4 mr-3 text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</NuxtLink>
<hr class="my-1 border-gray-200">
<button @click="logout" class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50">
<svg class="w-4 h-4 mr-3 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
<hr class="my-1 border-gray-200" />
<button
@click="logout"
class="flex items-center w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50"
>
<svg
class="w-4 h-4 mr-3 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Logout
</button>
@ -127,8 +257,8 @@
:class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path === '/admin'
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
]"
>
Dashboard
@ -139,8 +269,8 @@
:class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path.includes('/admin/members')
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
]"
>
Members
@ -151,8 +281,8 @@
:class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path.includes('/admin/events')
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
]"
>
Events
@ -163,8 +293,8 @@
:class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path.includes('/admin/series')
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50'
? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:text-primary hover:bg-primary-50',
]"
>
Series
@ -192,29 +322,29 @@
</template>
<script setup>
const showUserMenu = ref(false)
const showUserMenu = ref(false);
// Close user menu when clicking outside
const vClickOutside = {
beforeMount(el, binding) {
el.clickOutsideEvent = (event) => {
if (!(el === event.target || el.contains(event.target))) {
binding.value()
binding.value();
}
}
document.addEventListener('click', el.clickOutsideEvent)
};
document.addEventListener("click", el.clickOutsideEvent);
},
unmounted(el) {
document.removeEventListener('click', el.clickOutsideEvent)
}
}
document.removeEventListener("click", el.clickOutsideEvent);
},
};
const logout = async () => {
try {
await $fetch('/api/auth/logout', { method: 'POST' })
await navigateTo('/login')
await $fetch("/api/auth/logout", { method: "POST" });
await navigateTo("/login");
} catch (error) {
console.error('Logout failed:', error)
console.error("Logout failed:", error);
}
}
};
</script>

View file

@ -24,24 +24,15 @@
</p>
<ul
class="text-lg leading-relaxed text-[--ui-text-muted] space-y-3 mb-12"
class="list-disc pl-6 text-lg leading-relaxed text-[--ui-text-muted] space-y-3 mb-12"
>
<li>
<strong>Equal access:</strong> The entire knowledge commons, all
events, and full community participation
</li>
<li>
<strong>Equal voice:</strong> One member, one vote in all
decisions
</li>
<li>
<strong>Solidarity economics:</strong> Pay what you can
($0-50+/month), take what you need
</li>
<li>
<strong>Value Flow integration:</strong> Contribute your skills,
time, and knowledge - not just money
The entire knowledge commons, all events, and full community
participation on our private Slack
</li>
<li>One member, one vote in all decisions</li>
<li>Pay what you can ($0-50+/month)</li>
<li>Contribute your skills, time, and knowledge</li>
</ul>
</div>
</div>
@ -53,185 +44,129 @@
<UContainer>
<div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
Find Your Circle
Find your circle
</h2>
<p class="text-lg text-[--ui-text-muted] mb-12">
Circles help us provide relevant guidance and connect you with
others at similar stages. Choose based on where you are, not what
you want to access.
others at similar stages. Choose based on where you are now!
</p>
<div class="space-y-12">
<!-- Community Circle -->
<div class="bg-[--ui-bg] rounded-xl p-8">
<div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Community Circle
</h3>
<p class="text-lg text-[--ui-text-muted] mb-6">
You're exploring what cooperatives could mean for your work
</p>
<div class="mb-6">
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
Where you might be:
</h4>
<ul class="text-[--ui-text-muted] space-y-2">
<li>
Curious about alternatives to traditional studio structures
</li>
<li>Researching cooperative principles</li>
<li>Considering if a co-op is right for you</li>
<li>Supporting the movement as an ally</li>
</ul>
</div>
<div
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
>
<p>
Maybe you've heard rumours about cooperatives in game dev and
you're curious. Or you're frustrated with traditional studio
hierarchies and wondering if there's another way. This circle
is for anyone exploring whether cooperative principles might
fit their work.
</p>
<div class="mb-6">
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
We'll help you navigate:
</h4>
<ul class="text-[--ui-text-muted] space-y-2">
<li>Understanding cooperative basics</li>
<li>Connecting with others asking similar questions</li>
<li>Exploring real examples from game studios</li>
<li>Deciding your next steps</li>
</ul>
</div>
<p>
This space is for you if you're: an individual game worker
dreaming of different possibilities a researcher digging
into the rise of alternative studio models an industry ally
who wants to support cooperative work <em>anyone</em> who's
co-op-curious!
</p>
<div>
<h4 class="text-lg font-semibold text-[--ui-text] mb-3">
You might be:
</h4>
<ul class="text-[--ui-text-muted] space-y-2">
<li>Individual game workers</li>
<li>Researchers and students</li>
<li>Industry allies and supporters</li>
<li>Anyone co-op-curious</li>
</ul>
<p>
Our resources and community space will help you understand
cooperative basics, connect with others asking the same
questions, and give you a look at real examples from game
studios. You don't need to have a studio or project of your
own - just join and see what strikes your fancy!
</p>
</div>
</div>
<!-- Founder Circle -->
<div class="bg-[--ui-bg] rounded-xl p-8">
<div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Founder Circle
</h3>
<p class="text-lg text-[--ui-text-muted] mb-6">
You're actively building or transitioning to a cooperative model
</p>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
Where you might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Forming a new cooperative studio</li>
<li>Converting an existing studio to a co-op</li>
<li>Preparing to apply for the Peer Accelerator</li>
<li>Working through governance and structure decisions</li>
</ul>
</div>
<div
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
>
<p>
You're way past wondering about "what if" and into "how do we
actually do this?" Perhaps you're forming a new cooperative
studio from scratch, or converting an existing team to a co-op
structure, or working through the messy reality of turning
values into sustainable practice.
</p>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
We'll help you navigate:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Practical implementation challenges</li>
<li>Governance document creation</li>
<li>Financial planning for co-ops</li>
<li>Peer connections with other founders</li>
<li>Balancing ideals with sustainability</li>
</ul>
</div>
<p>
This is the space for the practical stuff: governance
documents you can read and adapt, financial models for
cooperative studios, connections with other founders
navigating similar challenges.
</p>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
Two paths available:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<p>
We have two paths through this circle that we will be
launching soon:
</p>
<ul>
<li>
<strong>Peer Accelerator Prep Track:</strong> Structured
preparation for the PA program
Peer Accelerator Prep Track <em>(coming soon)</em>
Structured preparation if you're planning to apply for the
PA program
</li>
<li>
<strong>Indie Track:</strong> Self-paced development for
alternative pathways
Indie Track <em>(coming soon)</em> Flexible, self-paced
support for teams building at their own pace
</li>
</ul>
</div>
<div>
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
You might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Founding teams</li>
<li>Studios in transition</li>
<li>PA program applicants</li>
<li>Solo founders exploring structures</li>
</ul>
<p>
Join us to figure out how you can balance your values with
keeping the lights on - whether you're a full founding team, a
solo founder exploring structures, or an existing studio in
transition.
</p>
</div>
</div>
<!-- Practitioner Circle -->
<div class="bg-[--ui-bg] rounded-xl p-8">
<div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Practitioner Circle
</h3>
<p class="text-lg text-[--ui-text-muted] mb-6">
You're operating a cooperative and contributing to the field
</p>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
Where you might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Running an established cooperative studio</li>
<li>Graduated from the Peer Accelerator</li>
<li>Mentoring other cooperatives</li>
<li>Advancing cooperative practices in games</li>
</ul>
</div>
<div
class="prose prose-lg dark:prose-invert max-w-none text-[--ui-text-muted]"
>
<p>
You've done it. You're actually running a
cooperative/worker-centric studio or you've been through our
Peer Accelerator. Now you're figuring out how to sustain it,
improve it, and maybe help others learn from what
<em>you've</em> learned.
</p>
<div class="mb-6">
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
We'll help you navigate:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Advanced operational challenges</li>
<li>Opportunities to mentor and teach</li>
<li>Contributing to best practices</li>
<li>Cross-pollination with other co-ops</li>
<li>Research and publication opportunities</li>
<li>Co-op to co-op collaboration</li>
</ul>
</div>
<p>
This circle is for: Peer Accelerator alumni members of
established co-ops mentors who want to support other
cooperatives researchers studying cooperative models in
practice
</p>
<div>
<h4
class="text-lg font-semibold text-gray-900 dark:text-white mb-3"
>
You might be:
</h4>
<ul class="text-gray-700 dark:text-gray-300 space-y-2">
<li>Peer Accelerator alumni</li>
<li>Established co-op members</li>
<li>Industry mentors</li>
<li>Cooperative researchers</li>
</ul>
<p>
Here, we create space for practitioners to share what's
actually working (and what isn't), support emerging
cooperatives, collaborate across studios, and contribute to
building a knowledge commons.
</p>
</div>
</div>
</div>

View file

@ -4,7 +4,9 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Admin Dashboard</h1>
<p class="text-gray-600">Manage Ghost Guild members, events, and community operations</p>
<p class="text-gray-600">
Manage Ghost Guild members, events, and community operations
</p>
</div>
</div>
</div>
@ -20,9 +22,21 @@
{{ stats.totalMembers || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"></path>
<div
class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"
></path>
</svg>
</div>
</div>
@ -36,9 +50,21 @@
{{ stats.activeEvents || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
<div
class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
</div>
</div>
@ -52,9 +78,21 @@
${{ stats.monthlyRevenue || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"></path>
<div
class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
></path>
</svg>
</div>
</div>
@ -68,9 +106,21 @@
{{ stats.pendingSlackInvites || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"></path>
<div
class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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"
></path>
</svg>
</div>
</div>
@ -81,16 +131,31 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
<div
class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Add New Member</h3>
<p class="text-gray-600 text-sm mb-4">
Add a new member to the Ghost Guild community
</p>
<button @click="navigateTo('/admin/members-working')" class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<button
@click="navigateTo('/admin/members-working')"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Manage Members
</button>
</div>
@ -98,16 +163,31 @@
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
<div
class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Create Event</h3>
<p class="text-gray-600 text-sm mb-4">
Schedule a new community event or workshop
</p>
<button @click="navigateTo('/admin/events-working')" class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
<button
@click="navigateTo('/admin/events-working')"
class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
>
Manage Events
</button>
</div>
@ -115,16 +195,31 @@
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
<div
class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">View Analytics</h3>
<p class="text-gray-600 text-sm mb-4">
Review member engagement and growth metrics
</p>
<button disabled class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed">
<button
disabled
class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed"
>
Coming Soon
</button>
</div>
@ -137,7 +232,10 @@
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Recent Members</h3>
<button @click="navigateTo('/admin/members-working')" class="text-sm text-blue-600 hover:text-blue-900">
<button
@click="navigateTo('/admin/members-working')"
class="text-sm text-primary-600 hover:text-primary-900"
>
View All
</button>
</div>
@ -145,19 +243,30 @@
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
></div>
</div>
<div v-else-if="recentMembers.length" class="space-y-3">
<div v-for="member in recentMembers" :key="member._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
<div
v-for="member in recentMembers"
:key="member._id"
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
>
<div>
<p class="font-medium">{{ member.name }}</p>
<p class="text-sm text-gray-600">{{ member.email }}</p>
</div>
<div class="text-right">
<span :class="getCircleBadgeClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
<span
:class="getCircleBadgeClasses(member.circle)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ member.circle }}
</span>
<p class="text-xs text-gray-500">{{ formatDate(member.createdAt) }}</p>
<p class="text-xs text-gray-500">
{{ formatDate(member.createdAt) }}
</p>
</div>
</div>
</div>
@ -171,7 +280,10 @@
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Upcoming Events</h3>
<button @click="navigateTo('/admin/events-working')" class="text-sm text-blue-600 hover:text-blue-900">
<button
@click="navigateTo('/admin/events-working')"
class="text-sm text-primary-600 hover:text-primary-900"
>
View All
</button>
</div>
@ -179,19 +291,32 @@
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
></div>
</div>
<div v-else-if="upcomingEvents.length" class="space-y-3">
<div v-for="event in upcomingEvents" :key="event._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
<div
v-for="event in upcomingEvents"
:key="event._id"
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
>
<div>
<p class="font-medium">{{ event.title }}</p>
<p class="text-sm text-gray-600">{{ formatDateTime(event.startDate) }}</p>
<p class="text-sm text-gray-600">
{{ formatDateTime(event.startDate) }}
</p>
</div>
<div class="text-right">
<span :class="getEventTypeBadgeClasses(event.eventType)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
<span
:class="getEventTypeBadgeClasses(event.eventType)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ event.eventType }}
</span>
<p class="text-xs text-gray-500">{{ event.location || 'Online' }}</p>
<p class="text-xs text-gray-500">
{{ event.location || "Online" }}
</p>
</div>
</div>
</div>
@ -207,44 +332,46 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: dashboardData, pending } = await useFetch('/api/admin/dashboard')
const { data: dashboardData, pending } = await useFetch("/api/admin/dashboard");
const stats = computed(() => dashboardData.value?.stats || {})
const recentMembers = computed(() => dashboardData.value?.recentMembers || [])
const upcomingEvents = computed(() => dashboardData.value?.upcomingEvents || [])
const stats = computed(() => dashboardData.value?.stats || {});
const recentMembers = computed(() => dashboardData.value?.recentMembers || []);
const upcomingEvents = computed(
() => dashboardData.value?.upcomingEvents || [],
);
const getCircleBadgeClasses = (circle) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
founder: 'bg-purple-100 text-purple-800',
practitioner: 'bg-green-100 text-green-800'
}
return classes[circle] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
founder: "bg-purple-100 text-purple-800",
practitioner: "bg-green-100 text-green-800",
};
return classes[circle] || "bg-gray-100 text-gray-800";
};
const getEventTypeBadgeClasses = (type) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
workshop: 'bg-green-100 text-green-800',
social: 'bg-purple-100 text-purple-800',
showcase: 'bg-orange-100 text-orange-800'
}
return classes[type] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
workshop: "bg-green-100 text-green-800",
social: "bg-purple-100 text-purple-800",
showcase: "bg-orange-100 text-orange-800",
};
return classes[type] || "bg-gray-100 text-gray-800";
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString()
}
return new Date(dateString).toLocaleDateString();
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
</script>

View file

@ -4,7 +4,9 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Event Management</h1>
<p class="text-gray-600">Create, manage, and monitor Ghost Guild events and workshops</p>
<p class="text-gray-600">
Create, manage, and monitor Ghost Guild events and workshops
</p>
</div>
</div>
</div>
@ -13,22 +15,35 @@
<!-- Search and Actions -->
<div class="mb-6 flex justify-between items-center">
<div class="flex gap-4 items-center">
<input v-model="searchQuery" placeholder="Search events..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<select v-model="typeFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<input
v-model="searchQuery"
placeholder="Search events..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<select
v-model="typeFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Types</option>
<option value="community">Community</option>
<option value="workshop">Workshop</option>
<option value="social">Social</option>
<option value="showcase">Showcase</option>
</select>
<select v-model="statusFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<select
v-model="statusFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Status</option>
<option value="upcoming">Upcoming</option>
<option value="ongoing">Ongoing</option>
<option value="past">Past</option>
</select>
</div>
<button @click="showCreateModal = true" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<button
@click="showCreateModal = true"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Create Event
</button>
</div>
@ -37,7 +52,9 @@
<div class="bg-white rounded-lg shadow overflow-hidden">
<div v-if="pending" class="p-8 text-center">
<div class="inline-flex items-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
></div>
Loading events...
</div>
</div>
@ -49,22 +66,57 @@
<table v-else class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Start Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Registration</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Title
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Type
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Start Date
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Registration
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="event in filteredEvents" :key="event._id" class="hover:bg-gray-50">
<tr
v-for="event in filteredEvents"
:key="event._id"
class="hover:bg-gray-50"
>
<td class="px-6 py-4">
<div class="text-sm font-medium text-gray-900">{{ event.title }}</div>
<div class="text-sm text-gray-500">{{ event.description.substring(0, 100) }}...</div>
<div class="text-sm font-medium text-gray-900">
{{ event.title }}
</div>
<div class="text-sm text-gray-500">
{{ event.description.substring(0, 100) }}...
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="getEventTypeClasses(event.eventType)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
<span
:class="getEventTypeClasses(event.eventType)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ event.eventType }}
</span>
</td>
@ -72,51 +124,94 @@
{{ formatDateTime(event.startDate) }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="getStatusClasses(event)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
<span
:class="getStatusClasses(event)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ getEventStatus(event) }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="event.registrationRequired ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
{{ event.registrationRequired ? 'Required' : 'Open' }}
<span
:class="
event.registrationRequired
? 'bg-blue-100 text-blue-800'
: 'bg-gray-100 text-gray-800'
"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ event.registrationRequired ? "Required" : "Open" }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div class="flex gap-2">
<button @click="editEvent(event)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
<button @click="duplicateEvent(event)" class="text-blue-600 hover:text-blue-900">Duplicate</button>
<button @click="deleteEvent(event)" class="text-red-600 hover:text-red-900">Delete</button>
<button
@click="editEvent(event)"
class="text-primary-600 hover:text-primary-900"
>
Edit
</button>
<button
@click="duplicateEvent(event)"
class="text-primary-600 hover:text-primary-900"
>
Duplicate
</button>
<button
@click="deleteEvent(event)"
class="text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="!pending && !error && filteredEvents.length === 0" class="p-8 text-center text-gray-500">
<div
v-if="!pending && !error && filteredEvents.length === 0"
class="p-8 text-center text-gray-500"
>
No events found matching your criteria
</div>
</div>
</div>
<!-- Create/Edit Event Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto">
<div
v-if="showCreateModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 overflow-y-auto"
>
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full mx-4 my-8">
<div class="px-6 py-4 border-b">
<h3 class="text-lg font-semibold">
{{ editingEvent ? 'Edit Event' : 'Create New Event' }}
{{ editingEvent ? "Edit Event" : "Create New Event" }}
</h3>
</div>
<form @submit.prevent="saveEvent" class="p-6 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">Event Title</label>
<input v-model="eventForm.title" placeholder="Enter event title" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Event Title</label
>
<input
v-model="eventForm.title"
placeholder="Enter event title"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Event Type</label>
<select v-model="eventForm.eventType" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Event Type</label
>
<select
v-model="eventForm.eventType"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="community">Community Meetup</option>
<option value="workshop">Workshop</option>
<option value="social">Social Event</option>
@ -125,58 +220,131 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Location</label>
<input v-model="eventForm.location" placeholder="Event location" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Location</label
>
<input
v-model="eventForm.location"
placeholder="Event location"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Start Date & Time</label>
<input v-model="eventForm.startDate" type="datetime-local" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Start Date & Time</label
>
<input
v-model="eventForm.startDate"
type="datetime-local"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">End Date & Time</label>
<input v-model="eventForm.endDate" type="datetime-local" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>End Date & Time</label
>
<input
v-model="eventForm.endDate"
type="datetime-local"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Max Attendees</label>
<input v-model="eventForm.maxAttendees" type="number" placeholder="Optional" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Max Attendees</label
>
<input
v-model="eventForm.maxAttendees"
type="number"
placeholder="Optional"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Registration Deadline</label>
<input v-model="eventForm.registrationDeadline" type="datetime-local" placeholder="Optional" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Registration Deadline</label
>
<input
v-model="eventForm.registrationDeadline"
type="datetime-local"
placeholder="Optional"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea v-model="eventForm.description" placeholder="Event description" required rows="3" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
<label class="block text-sm font-medium text-gray-700 mb-1"
>Description</label
>
<textarea
v-model="eventForm.description"
placeholder="Event description"
required
rows="3"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Additional Content</label>
<textarea v-model="eventForm.content" placeholder="Detailed event information, agenda, etc." rows="4" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"></textarea>
<label class="block text-sm font-medium text-gray-700 mb-1"
>Additional Content</label
>
<textarea
v-model="eventForm.content"
placeholder="Detailed event information, agenda, etc."
rows="4"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
></textarea>
</div>
<div class="flex items-center gap-6">
<label class="flex items-center">
<input v-model="eventForm.isOnline" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<input
v-model="eventForm.isOnline"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-700">Online Event</span>
</label>
<label class="flex items-center">
<input v-model="eventForm.registrationRequired" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
<span class="ml-2 text-sm text-gray-700">Registration Required</span>
<input
v-model="eventForm.registrationRequired"
type="checkbox"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span class="ml-2 text-sm text-gray-700"
>Registration Required</span
>
</label>
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" @click="cancelEdit" class="px-4 py-2 text-gray-600 hover:text-gray-900">
<button
type="button"
@click="cancelEdit"
class="px-4 py-2 text-gray-600 hover:text-gray-900"
>
Cancel
</button>
<button type="submit" :disabled="creating" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
{{ creating ? 'Saving...' : (editingEvent ? 'Update Event' : 'Create Event') }}
<button
type="submit"
:disabled="creating"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{
creating
? "Saving..."
: editingEvent
? "Update Event"
: "Create Event"
}}
</button>
</div>
</form>
@ -187,175 +355,185 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: events, pending, error, refresh } = await useFetch("/api/admin/events")
const {
data: events,
pending,
error,
refresh,
} = await useFetch("/api/admin/events");
const searchQuery = ref('')
const typeFilter = ref('')
const statusFilter = ref('')
const showCreateModal = ref(false)
const creating = ref(false)
const editingEvent = ref(null)
const searchQuery = ref("");
const typeFilter = ref("");
const statusFilter = ref("");
const showCreateModal = ref(false);
const creating = ref(false);
const editingEvent = ref(null);
const eventForm = reactive({
title: '',
description: '',
content: '',
startDate: '',
endDate: '',
eventType: 'community',
location: '',
title: "",
description: "",
content: "",
startDate: "",
endDate: "",
eventType: "community",
location: "",
isOnline: false,
maxAttendees: '',
maxAttendees: "",
registrationRequired: false,
registrationDeadline: ''
})
registrationDeadline: "",
});
const filteredEvents = computed(() => {
if (!events.value) return []
if (!events.value) return [];
return events.value.filter(event => {
const matchesSearch = !searchQuery.value ||
return events.value.filter((event) => {
const matchesSearch =
!searchQuery.value ||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
event.description.toLowerCase().includes(searchQuery.value.toLowerCase());
const matchesType = !typeFilter.value || event.eventType === typeFilter.value
const matchesType =
!typeFilter.value || event.eventType === typeFilter.value;
const eventStatus = getEventStatus(event)
const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value
const eventStatus = getEventStatus(event);
const matchesStatus =
!statusFilter.value || eventStatus.toLowerCase() === statusFilter.value;
return matchesSearch && matchesType && matchesStatus
})
})
return matchesSearch && matchesType && matchesStatus;
});
});
const getEventTypeClasses = (type) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
workshop: 'bg-green-100 text-green-800',
social: 'bg-purple-100 text-purple-800',
showcase: 'bg-orange-100 text-orange-800'
}
return classes[type] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
workshop: "bg-green-100 text-green-800",
social: "bg-purple-100 text-purple-800",
showcase: "bg-orange-100 text-orange-800",
};
return classes[type] || "bg-gray-100 text-gray-800";
};
const getEventStatus = (event) => {
const now = new Date()
const startDate = new Date(event.startDate)
const endDate = new Date(event.endDate)
const now = new Date();
const startDate = new Date(event.startDate);
const endDate = new Date(event.endDate);
if (now < startDate) return 'Upcoming'
if (now >= startDate && now <= endDate) return 'Ongoing'
return 'Past'
}
if (now < startDate) return "Upcoming";
if (now >= startDate && now <= endDate) return "Ongoing";
return "Past";
};
const getStatusClasses = (event) => {
const status = getEventStatus(event)
const status = getEventStatus(event);
const classes = {
'Upcoming': 'bg-blue-100 text-blue-800',
'Ongoing': 'bg-green-100 text-green-800',
'Past': 'bg-gray-100 text-gray-800'
}
return classes[status] || 'bg-gray-100 text-gray-800'
}
Upcoming: "bg-blue-100 text-blue-800",
Ongoing: "bg-green-100 text-green-800",
Past: "bg-gray-100 text-gray-800",
};
return classes[status] || "bg-gray-100 text-gray-800";
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleString()
}
return new Date(dateString).toLocaleString();
};
const saveEvent = async () => {
creating.value = true
creating.value = true;
try {
if (editingEvent.value) {
await $fetch(`/api/admin/events/${editingEvent.value._id}`, {
method: 'PUT',
body: eventForm
})
method: "PUT",
body: eventForm,
});
} else {
await $fetch('/api/admin/events', {
method: 'POST',
body: eventForm
})
await $fetch("/api/admin/events", {
method: "POST",
body: eventForm,
});
}
cancelEdit()
await refresh()
alert('Event saved successfully!')
cancelEdit();
await refresh();
alert("Event saved successfully!");
} catch (error) {
console.error('Failed to save event:', error)
alert('Failed to save event')
console.error("Failed to save event:", error);
alert("Failed to save event");
} finally {
creating.value = false
creating.value = false;
}
}
};
const editEvent = (event) => {
editingEvent.value = event
editingEvent.value = event;
Object.assign(eventForm, {
title: event.title,
description: event.description,
content: event.content || '',
content: event.content || "",
startDate: new Date(event.startDate).toISOString().slice(0, 16),
endDate: new Date(event.endDate).toISOString().slice(0, 16),
eventType: event.eventType,
location: event.location || '',
location: event.location || "",
isOnline: event.isOnline,
maxAttendees: event.maxAttendees || '',
maxAttendees: event.maxAttendees || "",
registrationRequired: event.registrationRequired,
registrationDeadline: event.registrationDeadline ? new Date(event.registrationDeadline).toISOString().slice(0, 16) : ''
})
showCreateModal.value = true
}
registrationDeadline: event.registrationDeadline
? new Date(event.registrationDeadline).toISOString().slice(0, 16)
: "",
});
showCreateModal.value = true;
};
const duplicateEvent = (event) => {
editingEvent.value = null
editingEvent.value = null;
Object.assign(eventForm, {
title: `${event.title} (Copy)`,
description: event.description,
content: event.content || '',
startDate: '',
endDate: '',
content: event.content || "",
startDate: "",
endDate: "",
eventType: event.eventType,
location: event.location || '',
location: event.location || "",
isOnline: event.isOnline,
maxAttendees: event.maxAttendees || '',
maxAttendees: event.maxAttendees || "",
registrationRequired: event.registrationRequired,
registrationDeadline: ''
})
showCreateModal.value = true
}
registrationDeadline: "",
});
showCreateModal.value = true;
};
const cancelEdit = () => {
showCreateModal.value = false
editingEvent.value = null
showCreateModal.value = false;
editingEvent.value = null;
Object.assign(eventForm, {
title: '',
description: '',
content: '',
startDate: '',
endDate: '',
eventType: 'community',
location: '',
title: "",
description: "",
content: "",
startDate: "",
endDate: "",
eventType: "community",
location: "",
isOnline: false,
maxAttendees: '',
maxAttendees: "",
registrationRequired: false,
registrationDeadline: ''
})
}
registrationDeadline: "",
});
};
const deleteEvent = async (event) => {
if (confirm(`Are you sure you want to delete "${event.title}"?`)) {
try {
await $fetch(`/api/admin/events/${event._id}`, {
method: 'DELETE'
})
await refresh()
alert('Event deleted successfully!')
method: "DELETE",
});
await refresh();
alert("Event deleted successfully!");
} catch (error) {
console.error('Failed to delete event:', error)
alert('Failed to delete event')
console.error("Failed to delete event:", error);
alert("Failed to delete event");
}
}
}
};
</script>

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,9 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Event Management</h1>
<p class="text-gray-600">Create, manage, and monitor Ghost Guild events and workshops</p>
<p class="text-gray-600">
Create, manage, and monitor Ghost Guild events and workshops
</p>
</div>
</div>
</div>
@ -13,27 +15,43 @@
<!-- Search and Actions -->
<div class="mb-6 flex justify-between items-center">
<div class="flex gap-4 items-center">
<input v-model="searchQuery" placeholder="Search events..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<select v-model="typeFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<input
v-model="searchQuery"
placeholder="Search events..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<select
v-model="typeFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Types</option>
<option value="community">Community</option>
<option value="workshop">Workshop</option>
<option value="social">Social</option>
<option value="showcase">Showcase</option>
</select>
<select v-model="statusFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<select
v-model="statusFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Status</option>
<option value="upcoming">Upcoming</option>
<option value="ongoing">Ongoing</option>
<option value="past">Past</option>
</select>
<select v-model="seriesFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<select
v-model="seriesFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Events</option>
<option value="series-only">Series Events Only</option>
<option value="standalone-only">Standalone Only</option>
</select>
</div>
<NuxtLink to="/admin/events/create" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 inline-flex items-center">
<NuxtLink
to="/admin/events/create"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 inline-flex items-center"
>
<Icon name="heroicons:plus" class="w-4 h-4 mr-2" />
Create Event
</NuxtLink>
@ -43,7 +61,9 @@
<div class="bg-white rounded-lg shadow overflow-hidden">
<div v-if="pending" class="p-8 text-center">
<div class="inline-flex items-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
></div>
Loading events...
</div>
</div>
@ -55,20 +75,53 @@
<table v-else class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Title</th>
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Type</th>
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Registration</th>
<th class="px-6 py-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th
class="px-6 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Title
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Type
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Date
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Status
</th>
<th
class="px-4 py-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Registration
</th>
<th
class="px-6 py-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="event in filteredEvents" :key="event._id" class="hover:bg-gray-50">
<tr
v-for="event in filteredEvents"
:key="event._id"
class="hover:bg-gray-50"
>
<!-- Title Column -->
<td class="px-6 py-6">
<div class="flex items-start space-x-3">
<div v-if="event.featureImage?.url && !event.featureImage?.publicId" class="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg overflow-hidden">
<div
v-if="
event.featureImage?.url && !event.featureImage?.publicId
"
class="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg overflow-hidden"
>
<img
:src="event.featureImage.url"
:alt="event.title"
@ -76,30 +129,63 @@
@error="handleImageError($event)"
/>
</div>
<div v-else class="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center">
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-gray-400" />
<div
v-else
class="flex-shrink-0 w-12 h-12 bg-gray-100 rounded-lg flex items-center justify-center"
>
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-gray-400"
/>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-semibold text-gray-900 mb-1">{{ event.title }}</div>
<div class="text-sm text-gray-500 line-clamp-2">{{ event.description.substring(0, 100) }}...</div>
<div class="text-sm font-semibold text-gray-900 mb-1">
{{ event.title }}
</div>
<div class="text-sm text-gray-500 line-clamp-2">
{{ event.description.substring(0, 100) }}...
</div>
<div v-if="event.series?.isSeriesEvent" class="mt-2 mb-2">
<div class="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 text-xs font-medium rounded-full">
<div class="w-4 h-4 bg-purple-200 text-purple-700 rounded-full flex items-center justify-center text-xs font-bold">
<div
class="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-700 text-xs font-medium rounded-full"
>
<div
class="w-4 h-4 bg-purple-200 text-purple-700 rounded-full flex items-center justify-center text-xs font-bold"
>
{{ event.series.position }}
</div>
{{ event.series.title }}
</div>
</div>
<div class="flex items-center space-x-4 mt-2">
<div v-if="event.membersOnly" class="flex items-center text-xs text-purple-600">
<Icon name="heroicons:lock-closed" class="w-3 h-3 mr-1" />
<div
v-if="event.membersOnly"
class="flex items-center text-xs text-purple-600"
>
<Icon
name="heroicons:lock-closed"
class="w-3 h-3 mr-1"
/>
Members Only
</div>
<div v-if="event.targetCircles && event.targetCircles.length > 0" class="flex items-center space-x-1">
<Icon name="heroicons:user-group" class="w-3 h-3 text-gray-400" />
<span class="text-xs text-gray-500">{{ event.targetCircles.join(', ') }}</span>
<div
v-if="
event.targetCircles && event.targetCircles.length > 0
"
class="flex items-center space-x-1"
>
<Icon
name="heroicons:user-group"
class="w-3 h-3 text-gray-400"
/>
<span class="text-xs text-gray-500">{{
event.targetCircles.join(", ")
}}</span>
</div>
<div v-if="!event.isVisible" class="flex items-center text-xs text-gray-500">
<div
v-if="!event.isVisible"
class="flex items-center text-xs text-gray-500"
>
<Icon name="heroicons:eye-slash" class="w-3 h-3 mr-1" />
Hidden
</div>
@ -110,7 +196,10 @@
<!-- Type Column -->
<td class="px-4 py-6 whitespace-nowrap">
<span :class="getEventTypeClasses(event.eventType)" class="inline-flex px-3 py-1 text-xs font-semibold rounded-full capitalize">
<span
:class="getEventTypeClasses(event.eventType)"
class="inline-flex px-3 py-1 text-xs font-semibold rounded-full capitalize"
>
{{ event.eventType }}
</span>
</td>
@ -118,18 +207,28 @@
<!-- Date Column -->
<td class="px-4 py-6 whitespace-nowrap text-sm text-gray-600">
<div class="space-y-1">
<div class="font-medium">{{ formatDate(event.startDate) }}</div>
<div class="text-xs text-gray-500">{{ formatTime(event.startDate) }}</div>
<div class="font-medium">
{{ formatDate(event.startDate) }}
</div>
<div class="text-xs text-gray-500">
{{ formatTime(event.startDate) }}
</div>
</div>
</td>
<!-- Status Column -->
<td class="px-4 py-6 whitespace-nowrap">
<div class="space-y-2">
<span :class="getStatusClasses(event)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
<span
:class="getStatusClasses(event)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ getEventStatus(event) }}
</span>
<div v-if="event.isCancelled" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800">
<div
v-if="event.isCancelled"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800"
>
Cancelled
</div>
</div>
@ -138,10 +237,16 @@
<!-- Registration Column -->
<td class="px-4 py-6 whitespace-nowrap">
<div class="space-y-2">
<div v-if="event.registrationRequired" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800">
<div
v-if="event.registrationRequired"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800"
>
Required
</div>
<div v-else class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800">
<div
v-else
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-800"
>
Optional
</div>
<div v-if="event.maxAttendees" class="text-xs text-gray-500">
@ -162,14 +267,14 @@
</NuxtLink>
<button
@click="editEvent(event)"
class="p-2 text-indigo-500 hover:text-indigo-700 hover:bg-indigo-50 rounded-full transition-colors"
class="p-2 text-primary-500 hover:text-primary-700 hover:bg-primary-50 rounded-full transition-colors"
title="Edit Event"
>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
</button>
<button
@click="duplicateEvent(event)"
class="p-2 text-blue-500 hover:text-blue-700 hover:bg-blue-50 rounded-full transition-colors"
class="p-2 text-primary-500 hover:text-primary-700 hover:bg-primary-50 rounded-full transition-colors"
title="Duplicate Event"
>
<Icon name="heroicons:document-duplicate" class="w-4 h-4" />
@ -187,155 +292,165 @@
</tbody>
</table>
<div v-if="!pending && !error && filteredEvents.length === 0" class="p-8 text-center text-gray-500">
<div
v-if="!pending && !error && filteredEvents.length === 0"
class="p-8 text-center text-gray-500"
>
No events found matching your criteria
</div>
</div>
</div>
</div>
</template>
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: events, pending, error, refresh } = await useFetch("/api/admin/events")
const {
data: events,
pending,
error,
refresh,
} = await useFetch("/api/admin/events");
const searchQuery = ref('')
const typeFilter = ref('')
const statusFilter = ref('')
const seriesFilter = ref('')
const searchQuery = ref("");
const typeFilter = ref("");
const statusFilter = ref("");
const seriesFilter = ref("");
const filteredEvents = computed(() => {
if (!events.value) return []
if (!events.value) return [];
return events.value.filter(event => {
const matchesSearch = !searchQuery.value ||
return events.value.filter((event) => {
const matchesSearch =
!searchQuery.value ||
event.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
event.description.toLowerCase().includes(searchQuery.value.toLowerCase())
event.description.toLowerCase().includes(searchQuery.value.toLowerCase());
const matchesType = !typeFilter.value || event.eventType === typeFilter.value
const matchesType =
!typeFilter.value || event.eventType === typeFilter.value;
const eventStatus = getEventStatus(event)
const matchesStatus = !statusFilter.value || eventStatus.toLowerCase() === statusFilter.value
const eventStatus = getEventStatus(event);
const matchesStatus =
!statusFilter.value || eventStatus.toLowerCase() === statusFilter.value;
const matchesSeries = !seriesFilter.value ||
(seriesFilter.value === 'series-only' && event.series?.isSeriesEvent) ||
(seriesFilter.value === 'standalone-only' && !event.series?.isSeriesEvent)
const matchesSeries =
!seriesFilter.value ||
(seriesFilter.value === "series-only" && event.series?.isSeriesEvent) ||
(seriesFilter.value === "standalone-only" &&
!event.series?.isSeriesEvent);
return matchesSearch && matchesType && matchesStatus && matchesSeries
})
})
return matchesSearch && matchesType && matchesStatus && matchesSeries;
});
});
const getEventTypeClasses = (type) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
workshop: 'bg-green-100 text-green-800',
social: 'bg-purple-100 text-purple-800',
showcase: 'bg-orange-100 text-orange-800'
}
return classes[type] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
workshop: "bg-green-100 text-green-800",
social: "bg-purple-100 text-purple-800",
showcase: "bg-orange-100 text-orange-800",
};
return classes[type] || "bg-gray-100 text-gray-800";
};
const getEventStatus = (event) => {
const now = new Date()
const startDate = new Date(event.startDate)
const endDate = new Date(event.endDate)
const now = new Date();
const startDate = new Date(event.startDate);
const endDate = new Date(event.endDate);
if (now < startDate) return 'Upcoming'
if (now >= startDate && now <= endDate) return 'Ongoing'
return 'Past'
}
if (now < startDate) return "Upcoming";
if (now >= startDate && now <= endDate) return "Ongoing";
return "Past";
};
const getStatusClasses = (event) => {
const status = getEventStatus(event)
const status = getEventStatus(event);
const classes = {
'Upcoming': 'bg-blue-100 text-blue-800',
'Ongoing': 'bg-green-100 text-green-800',
'Past': 'bg-gray-100 text-gray-800'
}
return classes[status] || 'bg-gray-100 text-gray-800'
}
Upcoming: "bg-blue-100 text-blue-800",
Ongoing: "bg-green-100 text-green-800",
Past: "bg-gray-100 text-gray-800",
};
return classes[status] || "bg-gray-100 text-gray-800";
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleString()
}
return new Date(dateString).toLocaleString();
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
return new Date(dateString).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const formatTime = (dateString) => {
return new Date(dateString).toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
})
}
return new Date(dateString).toLocaleTimeString("en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
});
};
// Get optimized Cloudinary image URL
const getOptimizedImageUrl = (publicId, transformations) => {
if (!publicId) return ''
const config = useRuntimeConfig()
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`
}
if (!publicId) return "";
const config = useRuntimeConfig();
return `https://res.cloudinary.com/${config.public.cloudinaryCloudName}/image/upload/${transformations}/f_auto,q_auto/${publicId}`;
};
const duplicateEvent = (event) => {
// Navigate to create page with duplicate query parameter
const duplicateData = {
title: `${event.title} (Copy)`,
description: event.description,
content: event.content || '',
content: event.content || "",
featureImage: event.featureImage || null,
eventType: event.eventType,
location: event.location || '',
location: event.location || "",
isOnline: event.isOnline,
isVisible: true,
isCancelled: false,
cancellationMessage: '',
cancellationMessage: "",
targetCircles: event.targetCircles || [],
maxAttendees: event.maxAttendees || '',
registrationRequired: event.registrationRequired
}
maxAttendees: event.maxAttendees || "",
registrationRequired: event.registrationRequired,
};
// Store duplicate data in session storage for the create page to use
sessionStorage.setItem('duplicateEventData', JSON.stringify(duplicateData))
navigateTo('/admin/events/create?duplicate=true')
}
sessionStorage.setItem("duplicateEventData", JSON.stringify(duplicateData));
navigateTo("/admin/events/create?duplicate=true");
};
const deleteEvent = async (event) => {
if (confirm(`Are you sure you want to delete "${event.title}"?`)) {
try {
await $fetch(`/api/admin/events/${String(event._id)}`, {
method: 'DELETE'
})
await refresh()
alert('Event deleted successfully!')
method: "DELETE",
});
await refresh();
alert("Event deleted successfully!");
} catch (error) {
console.error('Failed to delete event:', error)
alert('Failed to delete event')
console.error("Failed to delete event:", error);
alert("Failed to delete event");
}
}
}
};
const handleImageError = (event) => {
const img = event.target
const container = img?.parentElement
const img = event.target;
const container = img?.parentElement;
if (container) {
container.style.display = 'none'
container.style.display = "none";
}
}
};
const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${String(event._id)}`)
}
navigateTo(`/admin/events/create?edit=${String(event._id)}`);
};
</script>

View file

@ -4,7 +4,9 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Admin Dashboard</h1>
<p class="text-gray-600">Manage Ghost Guild members, events, and community operations</p>
<p class="text-gray-600">
Manage Ghost Guild members, events, and community operations
</p>
</div>
</div>
</div>
@ -20,9 +22,21 @@
{{ stats.totalMembers || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"></path>
<div
class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a4 4 0 110 5.292M4 19.5a4 4 0 010-5.292"
></path>
</svg>
</div>
</div>
@ -36,9 +50,21 @@
{{ stats.activeEvents || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
<div
class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
></path>
</svg>
</div>
</div>
@ -52,9 +78,21 @@
${{ stats.monthlyRevenue || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"></path>
<div
class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1"
></path>
</svg>
</div>
</div>
@ -68,9 +106,21 @@
{{ stats.pendingSlackInvites || 0 }}
</p>
</div>
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center">
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"></path>
<div
class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center"
>
<svg
class="w-6 h-6 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<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"
></path>
</svg>
</div>
</div>
@ -81,16 +131,31 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
<div
class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Add New Member</h3>
<p class="text-gray-600 text-sm mb-4">
Add a new member to the Ghost Guild community
</p>
<button @click="navigateTo('/admin/members')" class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<button
@click="navigateTo('/admin/members')"
class="w-full bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Manage Members
</button>
</div>
@ -98,16 +163,31 @@
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
<div
class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M12 6v6m0 0v6m0-6h6m-6 0H6"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">Create Event</h3>
<p class="text-gray-600 text-sm mb-4">
Schedule a new community event or workshop
</p>
<button @click="navigateTo('/admin/events')" class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
<button
@click="navigateTo('/admin/events')"
class="w-full bg-green-600 text-white py-2 px-4 rounded-lg hover:bg-green-700 focus:ring-2 focus:ring-green-500 focus:ring-offset-2"
>
Manage Events
</button>
</div>
@ -115,16 +195,31 @@
<div class="bg-white rounded-lg shadow p-6">
<div class="text-center">
<div class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
<div
class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
>
<svg
class="w-8 h-8 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
></path>
</svg>
</div>
<h3 class="text-lg font-semibold mb-2">View Analytics</h3>
<p class="text-gray-600 text-sm mb-4">
Review member engagement and growth metrics
</p>
<button disabled class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed">
<button
disabled
class="w-full bg-gray-300 text-gray-500 py-2 px-4 rounded-lg cursor-not-allowed"
>
Coming Soon
</button>
</div>
@ -137,7 +232,10 @@
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Recent Members</h3>
<button @click="navigateTo('/admin/members')" class="text-sm text-blue-600 hover:text-blue-900">
<button
@click="navigateTo('/admin/members')"
class="text-sm text-primary-600 hover:text-primary-900"
>
View All
</button>
</div>
@ -145,19 +243,30 @@
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
></div>
</div>
<div v-else-if="recentMembers.length" class="space-y-3">
<div v-for="member in recentMembers" :key="member._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
<div
v-for="member in recentMembers"
:key="member._id"
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
>
<div>
<p class="font-medium">{{ member.name }}</p>
<p class="text-sm text-gray-600">{{ member.email }}</p>
</div>
<div class="text-right">
<span :class="getCircleBadgeClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
<span
:class="getCircleBadgeClasses(member.circle)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ member.circle }}
</span>
<p class="text-xs text-gray-500">{{ formatDate(member.createdAt) }}</p>
<p class="text-xs text-gray-500">
{{ formatDate(member.createdAt) }}
</p>
</div>
</div>
</div>
@ -171,7 +280,10 @@
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Upcoming Events</h3>
<button @click="navigateTo('/admin/events')" class="text-sm text-blue-600 hover:text-blue-900">
<button
@click="navigateTo('/admin/events')"
class="text-sm text-primary-600 hover:text-primary-900"
>
View All
</button>
</div>
@ -179,19 +291,32 @@
<div class="p-6">
<div v-if="pending" class="text-center py-4">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mx-auto"
></div>
</div>
<div v-else-if="upcomingEvents.length" class="space-y-3">
<div v-for="event in upcomingEvents" :key="event._id" class="flex items-center justify-between p-3 rounded-lg border border-gray-200">
<div
v-for="event in upcomingEvents"
:key="event._id"
class="flex items-center justify-between p-3 rounded-lg border border-gray-200"
>
<div>
<p class="font-medium">{{ event.title }}</p>
<p class="text-sm text-gray-600">{{ formatDateTime(event.startDate) }}</p>
<p class="text-sm text-gray-600">
{{ formatDateTime(event.startDate) }}
</p>
</div>
<div class="text-right">
<span :class="getEventTypeBadgeClasses(event.eventType)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1">
<span
:class="getEventTypeBadgeClasses(event.eventType)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full mb-1"
>
{{ event.eventType }}
</span>
<p class="text-xs text-gray-500">{{ event.location || 'Online' }}</p>
<p class="text-xs text-gray-500">
{{ event.location || "Online" }}
</p>
</div>
</div>
</div>
@ -207,44 +332,46 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: dashboardData, pending } = await useFetch('/api/admin/dashboard')
const { data: dashboardData, pending } = await useFetch("/api/admin/dashboard");
const stats = computed(() => dashboardData.value?.stats || {})
const recentMembers = computed(() => dashboardData.value?.recentMembers || [])
const upcomingEvents = computed(() => dashboardData.value?.upcomingEvents || [])
const stats = computed(() => dashboardData.value?.stats || {});
const recentMembers = computed(() => dashboardData.value?.recentMembers || []);
const upcomingEvents = computed(
() => dashboardData.value?.upcomingEvents || [],
);
const getCircleBadgeClasses = (circle) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
founder: 'bg-purple-100 text-purple-800',
practitioner: 'bg-green-100 text-green-800'
}
return classes[circle] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
founder: "bg-purple-100 text-purple-800",
practitioner: "bg-green-100 text-green-800",
};
return classes[circle] || "bg-gray-100 text-gray-800";
};
const getEventTypeBadgeClasses = (type) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
workshop: 'bg-green-100 text-green-800',
social: 'bg-purple-100 text-purple-800',
showcase: 'bg-orange-100 text-orange-800'
}
return classes[type] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
workshop: "bg-green-100 text-green-800",
social: "bg-purple-100 text-purple-800",
showcase: "bg-orange-100 text-orange-800",
};
return classes[type] || "bg-gray-100 text-gray-800";
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString()
}
return new Date(dateString).toLocaleDateString();
};
const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
});
};
</script>

View file

@ -4,7 +4,9 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Member Management</h1>
<p class="text-gray-600">Manage Ghost Guild members, their contributions, and access levels</p>
<p class="text-gray-600">
Manage Ghost Guild members, their contributions, and access levels
</p>
</div>
</div>
</div>
@ -13,15 +15,25 @@
<!-- Search and Actions -->
<div class="mb-6 flex justify-between items-center">
<div class="flex gap-4 items-center">
<input v-model="searchQuery" placeholder="Search members..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<select v-model="circleFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<input
v-model="searchQuery"
placeholder="Search members..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<select
v-model="circleFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Circles</option>
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
</select>
</div>
<button @click="showCreateModal = true" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<button
@click="showCreateModal = true"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Add Member
</button>
</div>
@ -30,7 +42,9 @@
<div class="bg-white rounded-lg shadow overflow-hidden">
<div v-if="pending" class="p-8 text-center">
<div class="inline-flex items-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
></div>
Loading members...
</div>
</div>
@ -42,36 +56,82 @@
<table v-else class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Circle</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contribution</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slack Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Email
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Circle
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Contribution
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Slack Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Joined
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="member in filteredMembers" :key="member._id" class="hover:bg-gray-50">
<tr
v-for="member in filteredMembers"
:key="member._id"
class="hover:bg-gray-50"
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ member.name }}</div>
<div class="text-sm font-medium text-gray-900">
{{ member.name }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-600">{{ member.email }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="getCircleClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
<span
:class="getCircleClasses(member.circle)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ member.circle }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800"
>
${{ member.contributionTier }}/month
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="member.slackInvited ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
{{ member.slackInvited ? 'Invited' : 'Pending' }}
<span
:class="
member.slackInvited
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ member.slackInvited ? "Invited" : "Pending" }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
@ -79,22 +139,38 @@
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div class="flex gap-2">
<button @click="sendSlackInvite(member)" class="text-blue-600 hover:text-blue-900">Slack Invite</button>
<button @click="editMember(member)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
<button
@click="sendSlackInvite(member)"
class="text-primary-600 hover:text-primary-900"
>
Slack Invite
</button>
<button
@click="editMember(member)"
class="text-primary-600 hover:text-primary-900"
>
Edit
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="!pending && !error && filteredMembers.length === 0" class="p-8 text-center text-gray-500">
<div
v-if="!pending && !error && filteredMembers.length === 0"
class="p-8 text-center text-gray-500"
>
No members found matching your criteria
</div>
</div>
</div>
<!-- Create Member Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div
v-if="showCreateModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="px-6 py-4 border-b">
<h3 class="text-lg font-semibold">Add New Member</h3>
@ -102,18 +178,38 @@
<form @submit.prevent="createMember" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input v-model="newMember.name" placeholder="Full name" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Name</label
>
<input
v-model="newMember.name"
placeholder="Full name"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input v-model="newMember.email" type="email" placeholder="email@example.com" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Email</label
>
<input
v-model="newMember.email"
type="email"
placeholder="email@example.com"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Circle</label>
<select v-model="newMember.circle" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Circle</label
>
<select
v-model="newMember.circle"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
@ -121,8 +217,13 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contribution Tier</label>
<select v-model="newMember.contributionTier" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Contribution Tier</label
>
<select
v-model="newMember.contributionTier"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="0">$0/month</option>
<option value="5">$5/month</option>
<option value="15">$15/month</option>
@ -132,11 +233,19 @@
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" @click="showCreateModal = false" class="px-4 py-2 text-gray-600 hover:text-gray-900">
<button
type="button"
@click="showCreateModal = false"
class="px-4 py-2 text-gray-600 hover:text-gray-900"
>
Cancel
</button>
<button type="submit" :disabled="creating" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
{{ creating ? 'Creating...' : 'Create Member' }}
<button
type="submit"
:disabled="creating"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ creating ? "Creating..." : "Create Member" }}
</button>
</div>
</form>
@ -147,83 +256,90 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: members, pending, error, refresh } = await useFetch("/api/admin/members")
const {
data: members,
pending,
error,
refresh,
} = await useFetch("/api/admin/members");
const searchQuery = ref('')
const circleFilter = ref('')
const showCreateModal = ref(false)
const creating = ref(false)
const searchQuery = ref("");
const circleFilter = ref("");
const showCreateModal = ref(false);
const creating = ref(false);
const newMember = reactive({
name: '',
email: '',
circle: 'community',
contributionTier: '0'
})
name: "",
email: "",
circle: "community",
contributionTier: "0",
});
const filteredMembers = computed(() => {
if (!members.value) return []
if (!members.value) return [];
return members.value.filter(member => {
const matchesSearch = !searchQuery.value ||
return members.value.filter((member) => {
const matchesSearch =
!searchQuery.value ||
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
member.email.toLowerCase().includes(searchQuery.value.toLowerCase())
member.email.toLowerCase().includes(searchQuery.value.toLowerCase());
const matchesCircle = !circleFilter.value || member.circle === circleFilter.value
const matchesCircle =
!circleFilter.value || member.circle === circleFilter.value;
return matchesSearch && matchesCircle
})
})
return matchesSearch && matchesCircle;
});
});
const getCircleClasses = (circle) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
founder: 'bg-purple-100 text-purple-800',
practitioner: 'bg-green-100 text-green-800'
}
return classes[circle] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
founder: "bg-purple-100 text-purple-800",
practitioner: "bg-green-100 text-green-800",
};
return classes[circle] || "bg-gray-100 text-gray-800";
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString()
}
return new Date(dateString).toLocaleDateString();
};
const createMember = async () => {
creating.value = true
creating.value = true;
try {
await $fetch('/api/admin/members', {
method: 'POST',
body: newMember
})
await $fetch("/api/admin/members", {
method: "POST",
body: newMember,
});
showCreateModal.value = false
showCreateModal.value = false;
Object.assign(newMember, {
name: '',
email: '',
circle: 'community',
contributionTier: '0'
})
name: "",
email: "",
circle: "community",
contributionTier: "0",
});
await refresh()
alert('Member created successfully!')
await refresh();
alert("Member created successfully!");
} catch (error) {
console.error('Failed to create member:', error)
alert('Failed to create member')
console.error("Failed to create member:", error);
alert("Failed to create member");
} finally {
creating.value = false
creating.value = false;
}
}
};
const sendSlackInvite = (member) => {
alert(`Slack invite functionality would send invite to ${member.email}`)
console.log('Send Slack invite to:', member.email)
}
alert(`Slack invite functionality would send invite to ${member.email}`);
console.log("Send Slack invite to:", member.email);
};
const editMember = (member) => {
alert(`Edit functionality would open editor for ${member.name}`)
console.log('Edit member:', member._id)
}
alert(`Edit functionality would open editor for ${member.name}`);
console.log("Edit member:", member._id);
};
</script>

View file

@ -4,7 +4,9 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Member Management</h1>
<p class="text-gray-600">Manage Ghost Guild members, their contributions, and access levels</p>
<p class="text-gray-600">
Manage Ghost Guild members, their contributions, and access levels
</p>
</div>
</div>
</div>
@ -13,15 +15,25 @@
<!-- Search and Actions -->
<div class="mb-6 flex justify-between items-center">
<div class="flex gap-4 items-center">
<input v-model="searchQuery" placeholder="Search members..." class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<select v-model="circleFilter" class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<input
v-model="searchQuery"
placeholder="Search members..."
class="border border-gray-300 rounded-lg px-4 py-2 w-80 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
<select
v-model="circleFilter"
class="border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="">All Circles</option>
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
</select>
</div>
<button @click="showCreateModal = true" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<button
@click="showCreateModal = true"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
Add Member
</button>
</div>
@ -30,7 +42,9 @@
<div class="bg-white rounded-lg shadow overflow-hidden">
<div v-if="pending" class="p-8 text-center">
<div class="inline-flex items-center">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"></div>
<div
class="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600 mr-3"
></div>
Loading members...
</div>
</div>
@ -42,36 +56,82 @@
<table v-else class="w-full">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Circle</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contribution</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Slack Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Joined</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Name
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Email
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Circle
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Contribution
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Slack Status
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Joined
</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
>
Actions
</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="member in filteredMembers" :key="member._id" class="hover:bg-gray-50">
<tr
v-for="member in filteredMembers"
:key="member._id"
class="hover:bg-gray-50"
>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900">{{ member.name }}</div>
<div class="text-sm font-medium text-gray-900">
{{ member.name }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-600">{{ member.email }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="getCircleClasses(member.circle)" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
<span
:class="getCircleClasses(member.circle)"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ member.circle }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800">
<span
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800"
>
${{ member.contributionTier }}/month
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span :class="member.slackInvited ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'" class="inline-flex px-2 py-1 text-xs font-semibold rounded-full">
{{ member.slackInvited ? 'Invited' : 'Pending' }}
<span
:class="
member.slackInvited
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
"
class="inline-flex px-2 py-1 text-xs font-semibold rounded-full"
>
{{ member.slackInvited ? "Invited" : "Pending" }}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
@ -79,22 +139,38 @@
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-600">
<div class="flex gap-2">
<button @click="sendSlackInvite(member)" class="text-blue-600 hover:text-blue-900">Slack Invite</button>
<button @click="editMember(member)" class="text-indigo-600 hover:text-indigo-900">Edit</button>
<button
@click="sendSlackInvite(member)"
class="text-primary-600 hover:text-primary-900"
>
Slack Invite
</button>
<button
@click="editMember(member)"
class="text-primary-600 hover:text-primary-900"
>
Edit
</button>
</div>
</td>
</tr>
</tbody>
</table>
<div v-if="!pending && !error && filteredMembers.length === 0" class="p-8 text-center text-gray-500">
<div
v-if="!pending && !error && filteredMembers.length === 0"
class="p-8 text-center text-gray-500"
>
No members found matching your criteria
</div>
</div>
</div>
<!-- Create Member Modal -->
<div v-if="showCreateModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div
v-if="showCreateModal"
class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"
>
<div class="bg-white rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="px-6 py-4 border-b">
<h3 class="text-lg font-semibold">Add New Member</h3>
@ -102,18 +178,38 @@
<form @submit.prevent="createMember" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input v-model="newMember.name" placeholder="Full name" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Name</label
>
<input
v-model="newMember.name"
placeholder="Full name"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input v-model="newMember.email" type="email" placeholder="email@example.com" required class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
<label class="block text-sm font-medium text-gray-700 mb-1"
>Email</label
>
<input
v-model="newMember.email"
type="email"
placeholder="email@example.com"
required
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Circle</label>
<select v-model="newMember.circle" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Circle</label
>
<select
v-model="newMember.circle"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="community">Community</option>
<option value="founder">Founder</option>
<option value="practitioner">Practitioner</option>
@ -121,8 +217,13 @@
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Contribution Tier</label>
<select v-model="newMember.contributionTier" class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent">
<label class="block text-sm font-medium text-gray-700 mb-1"
>Contribution Tier</label
>
<select
v-model="newMember.contributionTier"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<option value="0">$0/month</option>
<option value="5">$5/month</option>
<option value="15">$15/month</option>
@ -132,11 +233,19 @@
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" @click="showCreateModal = false" class="px-4 py-2 text-gray-600 hover:text-gray-900">
<button
type="button"
@click="showCreateModal = false"
class="px-4 py-2 text-gray-600 hover:text-gray-900"
>
Cancel
</button>
<button type="submit" :disabled="creating" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed">
{{ creating ? 'Creating...' : 'Create Member' }}
<button
type="submit"
:disabled="creating"
class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{{ creating ? "Creating..." : "Create Member" }}
</button>
</div>
</form>
@ -147,83 +256,90 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const { data: members, pending, error, refresh } = await useFetch("/api/admin/members")
const {
data: members,
pending,
error,
refresh,
} = await useFetch("/api/admin/members");
const searchQuery = ref('')
const circleFilter = ref('')
const showCreateModal = ref(false)
const creating = ref(false)
const searchQuery = ref("");
const circleFilter = ref("");
const showCreateModal = ref(false);
const creating = ref(false);
const newMember = reactive({
name: '',
email: '',
circle: 'community',
contributionTier: '0'
})
name: "",
email: "",
circle: "community",
contributionTier: "0",
});
const filteredMembers = computed(() => {
if (!members.value) return []
if (!members.value) return [];
return members.value.filter(member => {
const matchesSearch = !searchQuery.value ||
return members.value.filter((member) => {
const matchesSearch =
!searchQuery.value ||
member.name.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
member.email.toLowerCase().includes(searchQuery.value.toLowerCase())
member.email.toLowerCase().includes(searchQuery.value.toLowerCase());
const matchesCircle = !circleFilter.value || member.circle === circleFilter.value
const matchesCircle =
!circleFilter.value || member.circle === circleFilter.value;
return matchesSearch && matchesCircle
})
})
return matchesSearch && matchesCircle;
});
});
const getCircleClasses = (circle) => {
const classes = {
community: 'bg-blue-100 text-blue-800',
founder: 'bg-purple-100 text-purple-800',
practitioner: 'bg-green-100 text-green-800'
}
return classes[circle] || 'bg-gray-100 text-gray-800'
}
community: "bg-blue-100 text-blue-800",
founder: "bg-purple-100 text-purple-800",
practitioner: "bg-green-100 text-green-800",
};
return classes[circle] || "bg-gray-100 text-gray-800";
};
const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString()
}
return new Date(dateString).toLocaleDateString();
};
const createMember = async () => {
creating.value = true
creating.value = true;
try {
await $fetch('/api/admin/members', {
method: 'POST',
body: newMember
})
await $fetch("/api/admin/members", {
method: "POST",
body: newMember,
});
showCreateModal.value = false
showCreateModal.value = false;
Object.assign(newMember, {
name: '',
email: '',
circle: 'community',
contributionTier: '0'
})
name: "",
email: "",
circle: "community",
contributionTier: "0",
});
await refresh()
alert('Member created successfully!')
await refresh();
alert("Member created successfully!");
} catch (error) {
console.error('Failed to create member:', error)
alert('Failed to create member')
console.error("Failed to create member:", error);
alert("Failed to create member");
} finally {
creating.value = false
creating.value = false;
}
}
};
const sendSlackInvite = (member) => {
alert(`Slack invite functionality would send invite to ${member.email}`)
console.log('Send Slack invite to:', member.email)
}
alert(`Slack invite functionality would send invite to ${member.email}`);
console.log("Send Slack invite to:", member.email);
};
const editMember = (member) => {
alert(`Edit functionality would open editor for ${member.name}`)
console.log('Edit member:', member._id)
}
alert(`Edit functionality would open editor for ${member.name}`);
console.log("Edit member:", member._id);
};
</script>

View file

@ -4,7 +4,9 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Series Management</h1>
<p class="text-gray-600">Manage event series and their relationships</p>
<p class="text-gray-600">
Manage event series and their relationships
</p>
</div>
</div>
</div>
@ -16,34 +18,51 @@
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-purple-100 rounded-full">
<Icon name="heroicons:squares-2x2" class="w-6 h-6 text-purple-600" />
<Icon
name="heroicons:squares-2x2"
class="w-6 h-6 text-purple-600"
/>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Active Series</p>
<p class="text-2xl font-semibold text-gray-900">{{ activeSeries.length }}</p>
<p class="text-2xl font-semibold text-gray-900">
{{ activeSeries.length }}
</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-blue-100 rounded-full">
<Icon name="heroicons:calendar-days" class="w-6 h-6 text-blue-600" />
<Icon
name="heroicons:calendar-days"
class="w-6 h-6 text-blue-600"
/>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Total Series Events</p>
<p class="text-2xl font-semibold text-gray-900">{{ totalSeriesEvents }}</p>
<p class="text-2xl font-semibold text-gray-900">
{{ totalSeriesEvents }}
</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center">
<div class="p-3 bg-green-100 rounded-full">
<Icon name="heroicons:chart-bar" class="w-6 h-6 text-green-600" />
<Icon
name="heroicons:chart-bar"
class="w-6 h-6 text-green-600"
/>
</div>
<div class="ml-4">
<p class="text-sm text-gray-500">Avg Events/Series</p>
<p class="text-2xl font-semibold text-gray-900">
{{ activeSeries.length > 0 ? Math.round(totalSeriesEvents / activeSeries.length) : 0 }}
{{
activeSeries.length > 0
? Math.round(totalSeriesEvents / activeSeries.length)
: 0
}}
</p>
</div>
</div>
@ -89,7 +108,9 @@
<!-- Series List -->
<div v-if="pending" class="text-center py-12">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-4"></div>
<div
class="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-500 mx-auto mb-4"
></div>
<p class="text-gray-600">Loading series...</p>
</div>
@ -103,24 +124,32 @@
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div :class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type)
]">
<div
:class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type),
]"
>
{{ formatSeriesType(series.type) }}
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">{{ series.title }}</h3>
<h3 class="text-lg font-semibold text-gray-900">
{{ series.title }}
</h3>
<p class="text-sm text-gray-600">{{ series.description }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active' ? 'bg-green-100 text-green-700' :
series.status === 'upcoming' ? 'bg-blue-100 text-blue-700' :
'bg-gray-100 text-gray-700'
]">
<span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active'
? 'bg-green-100 text-green-700'
: series.status === 'upcoming'
? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700',
]"
>
{{ series.status }}
</span>
<span class="text-sm text-gray-500">
@ -139,19 +168,27 @@
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div class="w-8 h-8 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold">
{{ event.series?.position || '?' }}
<div
class="w-8 h-8 bg-purple-100 text-purple-600 rounded-full flex items-center justify-center text-sm font-semibold"
>
{{ event.series?.position || "?" }}
</div>
<div>
<h4 class="text-sm font-medium text-gray-900">{{ event.title }}</h4>
<p class="text-xs text-gray-500">{{ formatEventDate(event.startDate) }}</p>
<h4 class="text-sm font-medium text-gray-900">
{{ event.title }}
</h4>
<p class="text-xs text-gray-500">
{{ formatEventDate(event.startDate) }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span :class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
getEventStatusClass(event)
]">
<span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
getEventStatusClass(event),
]"
>
{{ getEventStatus(event) }}
</span>
<div class="flex gap-1">
@ -164,7 +201,7 @@
</NuxtLink>
<button
@click="editEvent(event)"
class="p-1 text-gray-400 hover:text-purple-600 rounded"
class="p-1 text-gray-400 hover:text-primary rounded"
title="Edit Event"
>
<Icon name="heroicons:pencil-square" class="w-4 h-4" />
@ -189,15 +226,21 @@
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<div class="flex gap-2">
<button
@click="editSeries(series)"
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Edit Series
</button>
<button
@click="addEventToSeries(series)"
class="text-sm text-purple-600 hover:text-purple-700 font-medium"
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Add Event
</button>
<button
@click="duplicateSeries(series)"
class="text-sm text-blue-600 hover:text-blue-700 font-medium"
class="text-sm text-primary-600 hover:text-primary-700 font-medium"
>
Duplicate Series
</button>
@ -214,19 +257,32 @@
</div>
<div v-else class="text-center py-12 bg-white rounded-lg shadow">
<Icon name="heroicons:squares-2x2" class="w-12 h-12 text-gray-400 mx-auto mb-3" />
<Icon
name="heroicons:squares-2x2"
class="w-12 h-12 text-gray-400 mx-auto mb-3"
/>
<p class="text-gray-600">No event series found</p>
<p class="text-sm text-gray-500 mt-2">Create events and group them into series to get started</p>
<p class="text-sm text-gray-500 mt-2">
Create events and group them into series to get started
</p>
</div>
</div>
<!-- Bulk Operations Modal -->
<div v-if="showBulkModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50">
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<!-- Edit Series Modal -->
<div
v-if="editingSeriesId"
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
>
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Bulk Series Operations</h3>
<button @click="showBulkModal = false" class="text-gray-400 hover:text-gray-600">
<h3 class="text-lg font-semibold text-gray-900">Edit Series</h3>
<button
@click="cancelEditSeries"
class="text-gray-400 hover:text-gray-600"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
@ -234,17 +290,119 @@
<div class="p-6 space-y-6">
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">Series Management Tools</h4>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Series Title</label
>
<input
v-model="editingSeriesData.title"
type="text"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="e.g., Co-op Game Dev Workshop Series"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Description</label
>
<textarea
v-model="editingSeriesData.description"
rows="3"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Brief description of this series"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Series Type</label
>
<select
v-model="editingSeriesData.type"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="workshop_series">Workshop Series</option>
<option value="recurring_meetup">Recurring Meetup</option>
<option value="multi_day">Multi-Day Event</option>
<option value="course">Course</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2"
>Total Events (optional)</label
>
<input
v-model.number="editingSeriesData.totalEvents"
type="number"
min="1"
class="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="Leave empty for ongoing series"
/>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-200 flex justify-end gap-3">
<button
@click="cancelEditSeries"
class="px-4 py-2 text-gray-600 hover:text-gray-700"
>
Cancel
</button>
<button
@click="saveSeriesEdit"
class="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Save Changes
</button>
</div>
</div>
</div>
<!-- Bulk Operations Modal -->
<div
v-if="showBulkModal"
class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center p-4 z-50"
>
<div
class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"
>
<div class="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">
Bulk Series Operations
</h3>
<button
@click="showBulkModal = false"
class="text-gray-400 hover:text-gray-600"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
<div class="p-6 space-y-6">
<div>
<h4 class="text-sm font-medium text-gray-900 mb-3">
Series Management Tools
</h4>
<div class="space-y-3">
<button
@click="reorderAllSeries"
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:arrows-up-down" class="w-5 h-5 text-gray-400 mr-3" />
<Icon
name="heroicons:arrows-up-down"
class="w-5 h-5 text-gray-400 mr-3"
/>
<div>
<p class="text-sm font-medium text-gray-900">Auto-Reorder Series</p>
<p class="text-xs text-gray-500">Fix position numbers based on event dates</p>
<p class="text-sm font-medium text-gray-900">
Auto-Reorder Series
</p>
<p class="text-xs text-gray-500">
Fix position numbers based on event dates
</p>
</div>
</div>
</button>
@ -254,10 +412,17 @@
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:check-circle" class="w-5 h-5 text-gray-400 mr-3" />
<Icon
name="heroicons:check-circle"
class="w-5 h-5 text-gray-400 mr-3"
/>
<div>
<p class="text-sm font-medium text-gray-900">Validate Series Data</p>
<p class="text-xs text-gray-500">Check for consistency issues</p>
<p class="text-sm font-medium text-gray-900">
Validate Series Data
</p>
<p class="text-xs text-gray-500">
Check for consistency issues
</p>
</div>
</div>
</button>
@ -267,10 +432,17 @@
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
>
<div class="flex items-center">
<Icon name="heroicons:document-arrow-down" class="w-5 h-5 text-gray-400 mr-3" />
<Icon
name="heroicons:document-arrow-down"
class="w-5 h-5 text-gray-400 mr-3"
/>
<div>
<p class="text-sm font-medium text-gray-900">Export Series Data</p>
<p class="text-xs text-gray-500">Download series information as JSON</p>
<p class="text-sm font-medium text-gray-900">
Export Series Data
</p>
<p class="text-xs text-gray-500">
Download series information as JSON
</p>
</div>
</div>
</button>
@ -293,136 +465,152 @@
<script setup>
definePageMeta({
layout: 'admin'
})
layout: "admin",
});
const showBulkModal = ref(false)
const searchQuery = ref('')
const statusFilter = ref('')
const showBulkModal = ref(false);
const searchQuery = ref("");
const statusFilter = ref("");
const editingSeriesId = ref(null);
const editingSeriesData = ref({
title: "",
description: "",
type: "workshop_series",
totalEvents: null,
});
// Fetch series data
const { data: seriesData, pending, refresh } = await useFetch('/api/admin/series')
const {
data: seriesData,
pending,
refresh,
} = await useFetch("/api/admin/series");
// Computed properties
const activeSeries = computed(() => {
if (!seriesData.value) return []
return seriesData.value
})
if (!seriesData.value) return [];
return seriesData.value;
});
const totalSeriesEvents = computed(() => {
return activeSeries.value.reduce((sum, series) => sum + (series.eventCount || 0), 0)
})
return activeSeries.value.reduce(
(sum, series) => sum + (series.eventCount || 0),
0,
);
});
const filteredSeries = computed(() => {
if (!activeSeries.value) return []
if (!activeSeries.value) return [];
return activeSeries.value.filter(series => {
const matchesSearch = !searchQuery.value ||
return activeSeries.value.filter((series) => {
const matchesSearch =
!searchQuery.value ||
series.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
series.description.toLowerCase().includes(searchQuery.value.toLowerCase())
series.description
.toLowerCase()
.includes(searchQuery.value.toLowerCase());
const matchesStatus = !statusFilter.value || series.status === statusFilter.value
const matchesStatus =
!statusFilter.value || series.status === statusFilter.value;
return matchesSearch && matchesStatus
})
})
return matchesSearch && matchesStatus;
});
});
// Helper functions
const formatSeriesType = (type) => {
const types = {
'workshop_series': 'Workshop Series',
'recurring_meetup': 'Recurring Meetup',
'multi_day': 'Multi-Day Event',
'course': 'Course',
'tournament': 'Tournament'
}
return types[type] || type
}
workshop_series: "Workshop Series",
recurring_meetup: "Recurring Meetup",
multi_day: "Multi-Day Event",
course: "Course",
};
return types[type] || type;
};
const getSeriesTypeBadgeClass = (type) => {
const classes = {
'workshop_series': 'bg-emerald-100 text-emerald-700',
'recurring_meetup': 'bg-blue-100 text-blue-700',
'multi_day': 'bg-purple-100 text-purple-700',
'course': 'bg-amber-100 text-amber-700',
'tournament': 'bg-red-100 text-red-700'
}
return classes[type] || 'bg-gray-100 text-gray-700'
}
workshop_series: "bg-emerald-100 text-emerald-700",
recurring_meetup: "bg-blue-100 text-blue-700",
multi_day: "bg-purple-100 text-purple-700",
course: "bg-amber-100 text-amber-700",
};
return classes[type] || "bg-gray-100 text-gray-700";
};
const formatEventDate = (date) => {
return new Date(date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
})
}
return new Date(date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
};
const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return 'No dates'
if (!startDate || !endDate) return "No dates";
const start = new Date(startDate)
const end = new Date(endDate)
const start = new Date(startDate);
const end = new Date(endDate);
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric'
})
const formatter = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
});
return `${formatter.format(start)} - ${formatter.format(end)}`
}
return `${formatter.format(start)} - ${formatter.format(end)}`;
};
const getEventStatus = (event) => {
const now = new Date()
const startDate = new Date(event.startDate)
const endDate = new Date(event.endDate)
const now = new Date();
const startDate = new Date(event.startDate);
const endDate = new Date(event.endDate);
if (now < startDate) return 'Upcoming'
if (now >= startDate && now <= endDate) return 'Ongoing'
return 'Completed'
}
if (now < startDate) return "Upcoming";
if (now >= startDate && now <= endDate) return "Ongoing";
return "Completed";
};
const getEventStatusClass = (event) => {
const status = getEventStatus(event)
const status = getEventStatus(event);
const classes = {
'Upcoming': 'bg-blue-100 text-blue-700',
'Ongoing': 'bg-green-100 text-green-700',
'Completed': 'bg-gray-100 text-gray-700'
}
return classes[status] || 'bg-gray-100 text-gray-700'
}
Upcoming: "bg-blue-100 text-blue-700",
Ongoing: "bg-green-100 text-green-700",
Completed: "bg-gray-100 text-gray-700",
};
return classes[status] || "bg-gray-100 text-gray-700";
};
// Actions
const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${event.id}`)
}
navigateTo(`/admin/events/create?edit=${event.id}`);
};
const removeFromSeries = async (event) => {
if (!confirm(`Remove "${event.title}" from its series?`)) return
if (!confirm(`Remove "${event.title}" from its series?`)) return;
try {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
method: "PUT",
body: {
...event,
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
id: "",
title: "",
description: "",
type: "workshop_series",
position: 1,
totalEvents: null
}
}
})
totalEvents: null,
},
},
});
await refresh()
await refresh();
} catch (error) {
console.error('Failed to remove event from series:', error)
alert('Failed to remove event from series')
console.error("Failed to remove event from series:", error);
alert("Failed to remove event from series");
}
}
};
const addEventToSeries = (series) => {
// Navigate to create page with series pre-filled
@ -434,71 +622,121 @@ const addEventToSeries = (series) => {
description: series.description,
type: series.type,
position: (series.eventCount || 0) + 1,
totalEvents: series.totalEvents
}
}
totalEvents: series.totalEvents,
},
};
sessionStorage.setItem('seriesEventData', JSON.stringify(seriesData))
navigateTo('/admin/events/create?series=true')
}
sessionStorage.setItem("seriesEventData", JSON.stringify(seriesData));
navigateTo("/admin/events/create?series=true");
};
const duplicateSeries = (series) => {
// TODO: Implement series duplication
alert('Series duplication coming soon!')
}
alert("Series duplication coming soon!");
};
const editSeries = (series) => {
editingSeriesId.value = series.id;
editingSeriesData.value = {
title: series.title,
description: series.description,
type: series.type,
totalEvents: series.totalEvents,
};
};
const cancelEditSeries = () => {
editingSeriesId.value = null;
editingSeriesData.value = {
title: "",
description: "",
type: "workshop_series",
totalEvents: null,
};
};
const saveSeriesEdit = async () => {
if (!editingSeriesData.value.title) {
alert("Series title is required");
return;
}
try {
// Update the series record
await $fetch("/api/admin/series", {
method: "PUT",
body: {
id: editingSeriesId.value,
...editingSeriesData.value,
},
});
await refresh();
cancelEditSeries();
alert("Series updated successfully");
} catch (error) {
console.error("Failed to update series:", error);
alert("Failed to update series");
}
};
const deleteSeries = async (series) => {
if (!confirm(`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`)) return
if (
!confirm(
`Delete the entire "${series.title}" series? This will remove the series relationship from all ${series.eventCount} events.`,
)
)
return;
try {
// Update all events to remove series relationship
for (const event of series.events) {
await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT',
method: "PUT",
body: {
...event,
series: {
isSeriesEvent: false,
id: '',
title: '',
description: '',
type: 'workshop_series',
id: "",
title: "",
description: "",
type: "workshop_series",
position: 1,
totalEvents: null
}
}
})
totalEvents: null,
},
},
});
}
await refresh()
alert('Series deleted and events converted to standalone events')
await refresh();
alert("Series deleted and events converted to standalone events");
} catch (error) {
console.error('Failed to delete series:', error)
alert('Failed to delete series')
console.error("Failed to delete series:", error);
alert("Failed to delete series");
}
}
};
// Bulk operations
const reorderAllSeries = async () => {
// TODO: Implement auto-reordering
alert('Auto-reorder feature coming soon!')
}
alert("Auto-reorder feature coming soon!");
};
const validateAllSeries = async () => {
// TODO: Implement validation
alert('Validation feature coming soon!')
}
alert("Validation feature coming soon!");
};
const exportSeriesData = () => {
const dataStr = JSON.stringify(activeSeries.value, null, 2)
const dataBlob = new Blob([dataStr], { type: 'application/json' })
const url = URL.createObjectURL(dataBlob)
const link = document.createElement('a')
link.href = url
link.download = 'event-series-data.json'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
const dataStr = JSON.stringify(activeSeries.value, null, 2);
const dataBlob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement("a");
link.href = url;
link.download = "event-series-data.json";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
};
</script>

View file

@ -202,14 +202,10 @@
v-if="!event.isCancelled"
class="bg-ghost-800 rounded-xl p-8 border border-ghost-700"
>
<h3 class="text-xl font-bold text-ghost-100 mb-6">
Register for This Event
</h3>
<!-- Registration Status -->
<div v-if="registrationStatus === 'registered'" class="mb-6">
<!-- Already Registered Status -->
<div v-if="registrationStatus === 'registered'">
<div
class="p-4 bg-green-900/20 rounded-lg border border-green-800"
class="p-4 bg-green-900/20 rounded-lg border border-green-800 mb-6"
>
<div class="flex items-start justify-between">
<div>
@ -233,40 +229,51 @@
</div>
</div>
<!-- Member Gate Warning -->
<!-- Logged In - Can Register -->
<div
v-if="
event.membersOnly &&
!isMember &&
registrationStatus !== 'registered'
"
class="mb-6"
v-else-if="memberData && (!event.membersOnly || isMember)"
class="text-center"
>
<div
class="p-4 bg-amber-900/20 rounded-lg border border-amber-800"
<p class="text-lg text-ghost-200 mb-6">
You are logged in, {{ memberData.name }}.
</p>
<UButton
color="primary"
size="xl"
@click="handleRegistration"
:loading="isRegistering"
class="px-12 py-4"
>
<p class="font-semibold text-amber-300">Membership Required</p>
<p class="text-sm text-amber-400 mt-1">
{{ isRegistering ? "Registering..." : "Register Now" }}
</UButton>
</div>
<!-- Member Gate Warning -->
<div v-else-if="event.membersOnly && !isMember" class="text-center">
<div
class="p-6 bg-amber-900/20 rounded-lg border border-amber-800 mb-6"
>
<p class="font-semibold text-amber-300 text-lg mb-2">
Membership Required
</p>
<p class="text-amber-400">
This event is exclusive to Ghost Guild members. Join any
circle to gain access.
</p>
<NuxtLink
to="/join"
class="inline-flex items-center text-sm font-medium text-amber-300 hover:underline mt-2"
>
Become a member
</NuxtLink>
</div>
<NuxtLink to="/join">
<UButton color="primary" size="xl" class="px-12 py-4">
Become a Member to Register
</UButton>
</NuxtLink>
</div>
<!-- Registration Form -->
<form
v-if="registrationStatus !== 'registered'"
@submit.prevent="handleRegistration"
class="space-y-4"
>
<!-- Show form fields only for public events OR for logged-in members -->
<template v-if="!event.membersOnly || isMember">
<!-- Not Logged In - Show Registration Form -->
<div v-else>
<h3 class="text-xl font-bold text-ghost-100 mb-6">
Register for This Event
</h3>
<form @submit.prevent="handleRegistration" class="space-y-4">
<div>
<label
for="name"
@ -312,26 +319,22 @@
:options="membershipOptions"
/>
</div>
</template>
<div class="pt-4">
<UButton
v-if="!event.membersOnly || isMember"
type="submit"
color="primary"
size="lg"
block
:loading="isRegistering"
>
{{ isRegistering ? "Registering..." : "Register for Event" }}
</UButton>
<NuxtLink v-else to="/join" class="block">
<UButton color="primary" size="lg" block>
Become a Member to Register
<div class="pt-4">
<UButton
type="submit"
color="primary"
size="lg"
block
:loading="isRegistering"
>
{{
isRegistering ? "Registering..." : "Register for Event"
}}
</UButton>
</NuxtLink>
</div>
</form>
</div>
</form>
</div>
<!-- Event Capacity -->
<div

View file

@ -42,7 +42,7 @@
<div class="flex-1 min-w-0">
<div class="flex items-start gap-2 mb-1">
<h3
class="text-lg font-semibold text-ghost-100 group-hover:text-blue-400 transition-colors"
class="text-lg font-semibold text-ghost-100 group-hover:text-primary transition-colors"
>
{{ event.title }}
</h3>
@ -72,7 +72,7 @@
<Icon
name="heroicons:arrow-right"
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"
class="w-5 h-5 text-ghost-400 group-hover:text-primary group-hover:translate-x-1 transition-all flex-shrink-0 mt-1"
/>
</NuxtLink>
</div>
@ -128,99 +128,89 @@
</section>
<!-- Event Series -->
<section
<div v-if="activeSeries.length > 0" class="text-center mb-12">
<h2 class="text-3xl font-bold text-ghost-100 mb-8">
Current Event Series
</h2>
</div>
<div
v-if="activeSeries.length > 0"
class="py-20 bg-ghost-800 dark:bg-ghost-900"
class="space-y-6 max-w-6xl mx-auto mb-20"
>
<UContainer>
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-ghost-100 mb-8">
Active Event Series
</h2>
<p class="text-ghost-300 max-w-2xl mx-auto">
Multi-part workshops and recurring events designed to deepen your
knowledge and build community connections.
</p>
</div>
<div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto"
>
<div
v-for="series in activeSeries.slice(0, 6)"
:key="series.id"
class="bg-ghost-900 rounded-xl p-6 shadow-lg border border-ghost-700"
>
<div class="flex items-start justify-between mb-4">
<div
v-for="series in activeSeries.slice(0, 6)"
:key="series.id"
class="bg-ghost-900 rounded-xl p-6 shadow-lg border border-ghost-700"
:class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type),
]"
>
<div class="flex items-start justify-between mb-4">
<div
:class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type),
]"
>
{{ formatSeriesType(series.type) }}
</div>
<div class="flex items-center gap-1 text-xs text-ghost-400">
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
<span>{{ series.eventCount }} events</span>
</div>
</div>
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
{{ series.title }}
</h3>
<p class="text-sm text-ghost-300 mb-4 line-clamp-2">
{{ series.description }}
</p>
<div class="space-y-2 mb-4">
<div
v-for="event in series.events.slice(0, 3)"
:key="event.id"
class="flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<div
class="w-6 h-6 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-medium"
>
{{ event.series?.position || "?" }}
</div>
<span class="text-ghost-300 truncate">{{ event.title }}</span>
</div>
<span class="text-ghost-400">
{{ formatEventDate(event.startDate) }}
</span>
</div>
<div
v-if="series.events.length > 3"
class="text-xs text-ghost-400 text-center pt-1"
>
+{{ series.events.length - 3 }} more events
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="text-ghost-400">
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: series.status === 'upcoming'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
]"
>
{{ series.status }}
</span>
</div>
{{ formatSeriesType(series.type) }}
</div>
<div class="flex items-center gap-1 text-xs text-ghost-400">
<Icon name="heroicons:calendar-days" class="w-4 h-4" />
<span>{{ series.eventCount }} events</span>
</div>
</div>
</UContainer>
</section>
<h3 class="text-lg font-semibold text-ghost-100 mb-2">
{{ series.title }}
</h3>
<p class="text-sm text-ghost-300 mb-4 line-clamp-2">
{{ series.description }}
</p>
<div class="space-y-2 mb-4">
<div
v-for="(event, index) in series.events.slice(0, 3)"
:key="event.id"
class="flex items-center justify-between text-xs"
>
<div class="flex items-center gap-2">
<div
class="w-6 h-6 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400 rounded-full flex items-center justify-center text-xs font-medium"
>
{{ event.series?.position || index + 1 }}
</div>
<span class="text-ghost-300 truncate">{{ event.title }}</span>
</div>
<span class="text-ghost-400">
{{ formatEventDate(event.startDate) }}
</span>
</div>
<div
v-if="series.events.length > 3"
class="text-xs text-ghost-400 text-center pt-1"
>
+{{ series.events.length - 3 }} more events
</div>
</div>
<div class="flex items-center justify-between text-sm">
<div class="text-ghost-400">
{{ formatDateRange(series.startDate, series.endDate) }}
</div>
<span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
series.status === 'active'
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: series.status === 'upcoming'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
]"
>
{{ series.status }}
</span>
</div>
</div>
</div>
<!-- Attend Our Events -->
<section class="py-20 bg-ghost-800 dark:bg-ghost-900">
@ -354,11 +344,12 @@ const upcomingEvents = computed(() => {
// Format event date for display
const formatEventDate = (date) => {
const dateObj = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
}).format(date);
}).format(dateObj);
};
// Get optimized Cloudinary image URL

View file

@ -1,15 +1,23 @@
<template>
<div>
<!-- Page Header -->
<!-- Page Header - Context aware -->
<PageHeader
v-if="!isAuthenticated"
title="Join Ghost Guild"
subtitle="Become a member of our community and start building a more worker-centric future for games."
theme="gray"
size="large"
/>
<PageHeader
v-else
title="You're Already a Member!"
:subtitle="`Welcome back, ${memberData?.name || 'member'}. You're already part of Ghost Guild in the ${memberData?.circle || 'community'} circle.`"
theme="gray"
size="large"
/>
<!-- Membership Sign Up Form -->
<section class="py-20 bg-[--ui-bg]">
<section v-if="!isAuthenticated" class="py-20 bg-[--ui-bg]">
<UContainer class="max-w-4xl">
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4">
@ -325,6 +333,59 @@
</UContainer>
</section>
<!-- Member Info Section - Shows for logged-in members -->
<section v-if="isAuthenticated" class="py-20 bg-[--ui-bg]">
<UContainer class="max-w-4xl">
<div class="bg-[--ui-bg-elevated] rounded-xl p-8 mb-8">
<h2 class="text-2xl font-bold text-[--ui-text] mb-6">
Your Membership
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="bg-[--ui-bg] rounded-lg p-6">
<h3 class="text-sm font-medium text-[--ui-text-muted] mb-2">
Circle
</h3>
<p class="text-xl font-semibold text-[--ui-text] capitalize">
{{ memberData?.circle || "Community" }}
</p>
</div>
<div class="bg-[--ui-bg] rounded-lg p-6">
<h3 class="text-sm font-medium text-[--ui-text-muted] mb-2">
Contribution
</h3>
<p class="text-xl font-semibold text-[--ui-text]">
${{ memberData?.contributionTier || "0" }} CAD/month
</p>
</div>
</div>
<div class="flex flex-col sm:flex-row gap-4">
<UButton to="/member/dashboard" size="lg">
Go to Dashboard
</UButton>
<UButton to="/member/profile" variant="outline" size="lg">
Edit Profile
</UButton>
</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-6">
<h3 class="text-lg font-semibold text-[--ui-text] mb-3">
Want to change your circle or contribution?
</h3>
<p class="text-[--ui-text] mb-4">
You can update your circle and adjust your monthly contribution at
any time from your profile settings.
</p>
<UButton to="/member/profile" variant="soft" color="primary">
Update Membership Settings
</UButton>
</div>
</UContainer>
</section>
<!-- How Ghost Guild Works -->
<section class="py-20 bg-[--ui-bg-elevated]">
<UContainer>
@ -361,10 +422,10 @@
Circle-Specific Guidance
</h3>
<ul class="text-[--ui-text] space-y-2">
<li>Curated resources for your stage</li>
<li>Connection with peers on similar journeys</li>
<li>Relevant workshop recommendations</li>
<li>Targeted support for your challenges</li>
<li>Resources for your stage</li>
<li>Connection with peers</li>
<li>Workshop recommendations</li>
<li>Support for your challenges</li>
</ul>
</div>
</div>
@ -453,6 +514,14 @@ import {
getContributionTierByValue,
} from "~/config/contributions";
// Auth state
const { isAuthenticated, memberData, checkMemberStatus } = useAuth();
// Check authentication status on mount
onMounted(async () => {
await checkMemberStatus();
});
// Form state
const form = reactive({
email: "",
@ -492,7 +561,6 @@ const {
verifyPayment,
cleanup: cleanupHelcimPay,
} = useHelcimPay();
const { checkMemberStatus } = useAuth();
// Form validation
const isFormValid = computed(() => {

View file

@ -173,14 +173,28 @@
<h2 class="text-xl font-bold text-ghost-100 ethereal-text">
Your Upcoming Events
</h2>
<UButton
to="/events"
variant="ghost"
size="sm"
class="text-ghost-300 hover:text-ghost-100"
>
Browse All Events
</UButton>
<div class="flex items-center gap-2">
<UButton
v-if="registeredEvents.length > 0"
@click="copyCalendarLink"
variant="ghost"
size="sm"
class="text-ghost-300 hover:text-ghost-100"
icon="heroicons:calendar"
>
{{
calendarLinkCopied ? "Link Copied!" : "Get Calendar Link"
}}
</UButton>
<UButton
to="/events"
variant="ghost"
size="sm"
class="text-ghost-300 hover:text-ghost-100"
>
Browse All Events
</UButton>
</div>
</div>
</template>
@ -261,6 +275,46 @@
Browse Events
</UButton>
</div>
<!-- Calendar subscription instructions -->
<div
v-if="registeredEvents.length > 0 && showCalendarInstructions"
class="mt-4 p-4 bg-ghost-800 border border-ghost-600"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1">
<h4 class="text-sm font-semibold text-ghost-100 mb-2">
How to Subscribe to Your Calendar
</h4>
<ul
class="text-xs text-ghost-300 space-y-1 list-disc list-inside"
>
<li>
<strong>Google Calendar:</strong> Click "+" "From URL"
Paste the link
</li>
<li>
<strong>Apple Calendar:</strong> File New Calendar
Subscription Paste the link
</li>
<li>
<strong>Outlook:</strong> Add Calendar Subscribe from web
Paste the link
</li>
</ul>
<p class="text-xs text-ghost-400 mt-2">
Your calendar will automatically update when you register or
unregister from events.
</p>
</div>
<button
@click="showCalendarInstructions = false"
class="text-ghost-400 hover:text-ghost-200"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" />
</button>
</div>
</div>
</UCard>
</div>
</UContainer>
@ -272,6 +326,33 @@ const { memberData, checkMemberStatus } = useAuth();
const registeredEvents = ref([]);
const loadingEvents = ref(false);
const calendarLinkCopied = ref(false);
const showCalendarInstructions = ref(false);
// Calendar subscription URL
const calendarUrl = computed(() => {
const memberId = memberData.value?._id || memberData.value?.id;
if (!memberId) return "";
const config = useRuntimeConfig();
const baseUrl = config.public.appUrl || "http://localhost:3000";
// Use webcal protocol for calendar subscription
const webcalUrl = baseUrl.replace(/^https?:/, "webcal:");
return `${webcalUrl}/api/members/my-calendar?memberId=${memberId}`;
});
// Copy calendar subscription link to clipboard
const copyCalendarLink = async () => {
try {
await navigator.clipboard.writeText(calendarUrl.value);
calendarLinkCopied.value = true;
showCalendarInstructions.value = true;
setTimeout(() => {
calendarLinkCopied.value = false;
}, 2000);
} catch (err) {
console.error("Failed to copy calendar link:", err);
}
};
// Handle authentication check on page load
const { pending: authPending } = await useLazyAsyncData(

View file

@ -343,20 +343,6 @@
Peer Support
</h2>
<div
class="mb-6 backdrop-blur-sm bg-purple-500/10 border border-purple-500/30 rounded-lg p-4"
>
<p class="text-ghost-300 text-sm leading-relaxed">
Offer guidance to fellow members through the
<NuxtLink
to="/peer-support"
class="text-purple-400 hover:text-purple-300 underline"
>
Peer Support directory
</NuxtLink>
</p>
</div>
<div class="space-y-6">
<!-- Enable Toggle -->
<div class="flex items-start gap-4">
@ -416,7 +402,7 @@
!formData.peerSupportSkillTopics?.includes(t),
)"
:key="tag"
class="inline-block ml-2 text-blue-400 hover:text-blue-300 cursor-pointer underline"
class="inline-block ml-2 text-primary-400 hover:text-primary-300 cursor-pointer underline"
@click="addSuggestedSkillTopic(tag)"
>
{{ tag }}
@ -640,9 +626,6 @@
<p class="font-medium text-ghost-100">
{{ tier.label }}
</p>
<p class="text-sm text-ghost-400 mt-1">
{{ tier.features[0] }}
</p>
</div>
<div
v-if="selectedContributionTier === tier.value"

View file

@ -66,7 +66,7 @@
<button
v-if="availableSkills && availableSkills.length > 10"
type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
class="px-3 py-1 text-sm text-primary hover:text-primary-600"
@click="showAllSkills = !showAllSkills"
>
{{
@ -104,7 +104,7 @@
<button
v-if="availableTopics && availableTopics.length > 10"
type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300"
class="px-3 py-1 text-sm text-primary hover:text-primary-600"
@click="showAllTopics = !showAllTopics"
>
{{
@ -134,7 +134,7 @@
{{ circleLabels[selectedCircle] }}
<button
type="button"
class="hover:text-purple-200"
class="hover:text-primary"
@click="clearCircleFilter"
>
×
@ -147,7 +147,7 @@
Offering Peer Support
<button
type="button"
class="hover:text-purple-200"
class="hover:text-primary"
@click="clearPeerSupportFilter"
>
×
@ -156,7 +156,7 @@
<button
v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
type="button"
class="text-purple-400 hover:text-purple-300"
class="text-primary hover:text-primary-600"
@click="clearAllFilters"
>
Clear all filters

View file

@ -0,0 +1,62 @@
import Series from '../../models/series.js'
import Event from '../../models/event.js'
import { connectDB } from '../../utils/mongoose.js'
export default defineEventHandler(async (event) => {
try {
await connectDB()
const body = await readBody(event)
const { id, title, description, type, totalEvents } = body
if (!id || !title) {
throw createError({
statusCode: 400,
statusMessage: 'Series ID and title are required'
})
}
// Update the series record
const updatedSeries = await Series.findOneAndUpdate(
{ id },
{
title,
description,
type,
totalEvents: totalEvents || null
},
{ new: true }
)
if (!updatedSeries) {
throw createError({
statusCode: 404,
statusMessage: 'Series not found'
})
}
// Update all events in this series with the new metadata
await Event.updateMany(
{
'series.id': id,
'series.isSeriesEvent': true
},
{
$set: {
'series.title': title,
'series.description': description,
'series.type': type,
'series.totalEvents': totalEvents || null
}
}
)
return updatedSeries
} catch (error) {
console.error('Error updating series:', error)
throw createError({
statusCode: 500,
statusMessage: 'Failed to update series'
})
}
})

View file

@ -1,14 +1,15 @@
import Event from '../../../models/event';
import Event from "../../../models/event";
import { sendEventCancellationEmail } from "../../../utils/resend.js";
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id');
const id = getRouterParam(event, "id");
const body = await readBody(event);
const { email } = body;
if (!email) {
throw createError({
statusCode: 400,
statusMessage: 'Email is required'
statusMessage: "Email is required",
});
}
@ -24,22 +25,31 @@ export default defineEventHandler(async (event) => {
if (!eventDoc) {
throw createError({
statusCode: 404,
statusMessage: 'Event not found'
statusMessage: "Event not found",
});
}
// Find the registration index
const registrationIndex = eventDoc.registrations.findIndex(
registration => registration.email.toLowerCase() === email.toLowerCase()
(registration) =>
registration.email.toLowerCase() === email.toLowerCase(),
);
if (registrationIndex === -1) {
throw createError({
statusCode: 404,
statusMessage: 'Registration not found'
statusMessage: "Registration not found",
});
}
// Store registration data before removing (convert to plain object)
const registration = {
name: eventDoc.registrations[registrationIndex].name,
email: eventDoc.registrations[registrationIndex].email,
membershipLevel:
eventDoc.registrations[registrationIndex].membershipLevel,
};
// Remove the registration
eventDoc.registrations.splice(registrationIndex, 1);
@ -48,13 +58,26 @@ export default defineEventHandler(async (event) => {
await eventDoc.save();
// Send cancellation confirmation email
try {
const eventData = {
title: eventDoc.title,
slug: eventDoc.slug,
_id: eventDoc._id,
};
await sendEventCancellationEmail(registration, eventData);
} catch (emailError) {
// Log error but don't fail the cancellation
console.error("Failed to send cancellation email:", emailError);
}
return {
success: true,
message: 'Registration cancelled successfully',
registeredCount: eventDoc.registeredCount
message: "Registration cancelled successfully",
registeredCount: eventDoc.registeredCount,
};
} catch (error) {
console.error('Error cancelling registration:', error);
console.error("Error cancelling registration:", error);
// Re-throw known errors
if (error.statusCode) {
@ -63,7 +86,7 @@ export default defineEventHandler(async (event) => {
throw createError({
statusCode: 500,
statusMessage: 'Failed to cancel registration'
statusMessage: "Failed to cancel registration",
});
}
});

View file

@ -1,6 +1,7 @@
import Event from "../../../models/event.js";
import Member from "../../../models/member.js";
import { connectDB } from "../../../utils/mongoose.js";
import { sendEventRegistrationEmail } from "../../../utils/resend.js";
import mongoose from "mongoose";
export default defineEventHandler(async (event) => {
@ -102,7 +103,7 @@ export default defineEventHandler(async (event) => {
}
// Add registration
eventData.registrations.push({
const registration = {
memberId: member ? member._id : null,
name: body.name,
email: body.email.toLowerCase(),
@ -112,13 +113,20 @@ export default defineEventHandler(async (event) => {
amountPaid: 0,
dietary: body.dietary || false,
registeredAt: new Date(),
});
};
eventData.registrations.push(registration);
// Save the updated event
await eventData.save();
// TODO: Send confirmation email using Resend
// await sendEventRegistrationEmail(body.email, eventData)
// Send confirmation email using Resend
try {
await sendEventRegistrationEmail(registration, eventData);
} catch (emailError) {
// Log error but don't fail the registration
console.error("Failed to send confirmation email:", emailError);
}
return {
success: true,

View file

@ -0,0 +1,114 @@
import Event from "../../models/event";
import Member from "../../models/member";
export default defineEventHandler(async (event) => {
const query = getQuery(event);
const { memberId } = query;
if (!memberId) {
throw createError({
statusCode: 400,
statusMessage: "Member ID is required",
});
}
try {
// Verify member exists
const member = await Member.findById(memberId);
if (!member) {
throw createError({
statusCode: 404,
statusMessage: "Member not found",
});
}
// Find all events where the user is registered
const events = await Event.find({
"registrations.memberId": memberId,
isCancelled: { $ne: true },
})
.select("title slug description startDate endDate location")
.sort({ startDate: 1 });
// Generate iCal format
const ical = generateICalendar(events, member);
// Set headers for calendar subscription (not download)
setHeader(event, "Content-Type", "text/calendar; charset=utf-8");
setHeader(event, "Cache-Control", "no-cache, no-store, must-revalidate");
setHeader(event, "Pragma", "no-cache");
setHeader(event, "Expires", "0");
return ical;
} catch (error) {
console.error("Error generating calendar:", error);
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: "Failed to generate calendar",
});
}
});
function generateICalendar(events, member) {
const now = new Date();
const timestamp = now
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
let ical = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Ghost Guild//Events Calendar//EN",
"CALSCALE:GREGORIAN",
"METHOD:PUBLISH",
"X-WR-CALNAME:Ghost Guild - My Events",
"X-WR-TIMEZONE:UTC",
"X-WR-CALDESC:Your registered Ghost Guild events",
"REFRESH-INTERVAL;VALUE=DURATION:PT1H",
"X-PUBLISHED-TTL:PT1H",
];
events.forEach((evt) => {
const eventStart = new Date(evt.startDate);
const eventEnd = new Date(evt.endDate);
const dtstart = eventStart
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
const dtend = eventEnd
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
const dtstamp = timestamp;
// Clean description for iCal format
const description = (evt.description || "")
.replace(/\n/g, "\\n")
.replace(/,/g, "\\,");
const eventUrl = `https://ghostguild.org/events/${evt.slug || evt._id}`;
ical.push("BEGIN:VEVENT");
ical.push(`UID:${evt._id}@ghostguild.org`);
ical.push(`DTSTAMP:${dtstamp}`);
ical.push(`DTSTART:${dtstart}`);
ical.push(`DTEND:${dtend}`);
ical.push(`SUMMARY:${evt.title}`);
ical.push(`DESCRIPTION:${description}\\n\\nView event: ${eventUrl}`);
ical.push(`LOCATION:${evt.location || "Online"}`);
ical.push(`URL:${eventUrl}`);
ical.push(`STATUS:CONFIRMED`);
ical.push("END:VEVENT");
});
ical.push("END:VCALENDAR");
return ical.join("\r\n");
}

View file

@ -9,19 +9,13 @@ export const CONTRIBUTION_TIERS = {
label: "$0 - I need support right now",
tier: "free",
helcimPlanId: null, // No Helcim plan needed for free tier
features: ["Access to basic resources", "Community forum access"],
},
SUPPORTER: {
value: "5",
amount: 5,
label: "$5 - I can contribute a little",
label: "$5 - I can contribute",
tier: "supporter",
helcimPlanId: 20162,
features: [
"All Free Membership benefits",
"Priority community support",
"Early access to events",
],
},
MEMBER: {
value: "15",
@ -29,12 +23,6 @@ export const CONTRIBUTION_TIERS = {
label: "$15 - I can sustain the community",
tier: "member",
helcimPlanId: 21596,
features: [
"All Supporter benefits",
"Access to premium workshops",
"Monthly 1-on-1 sessions",
"Advanced resource library",
],
},
ADVOCATE: {
value: "30",
@ -42,12 +30,6 @@ export const CONTRIBUTION_TIERS = {
label: "$30 - I can support others too",
tier: "advocate",
helcimPlanId: 21597,
features: [
"All Member benefits",
"Weekly group mentoring",
"Access to exclusive events",
"Direct messaging with experts",
],
},
CHAMPION: {
value: "50",
@ -55,13 +37,6 @@ export const CONTRIBUTION_TIERS = {
label: "$50 - I want to sponsor multiple members",
tier: "champion",
helcimPlanId: 21598,
features: [
"All Advocate benefits",
"Personal mentoring sessions",
"VIP event access",
"Custom project support",
"Annual strategy session",
],
},
};

263
server/utils/resend.js Normal file
View file

@ -0,0 +1,263 @@
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
/**
* Send event registration confirmation email
*/
export async function sendEventRegistrationEmail(registration, eventData) {
const formatDate = (dateString) => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}).format(date);
};
const formatTime = (startDate, endDate) => {
const start = new Date(startDate);
const end = new Date(endDate);
const timeFormat = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "2-digit",
timeZoneName: "short",
});
return `${timeFormat.format(start)} - ${timeFormat.format(end)}`;
};
try {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@babyghosts.org>",
to: [registration.email],
subject: `You're registered for ${eventData.title}`,
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #1a1a2e;
color: #fff;
padding: 30px;
text-align: center;
border-radius: 8px 8px 0 0;
}
.content {
background-color: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.event-details {
background-color: #fff;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
border-left: 4px solid #3b82f6;
}
.detail-row {
margin: 10px 0;
}
.label {
font-weight: 600;
color: #666;
font-size: 14px;
}
.value {
color: #1a1a2e;
font-size: 16px;
}
.button {
display: inline-block;
background-color: #3b82f6;
color: #fff;
padding: 12px 30px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
color: #666;
font-size: 14px;
}
</style>
</head>
<body>
<div class="header">
<h1 style="margin: 0;">You're Registered! 🎉</h1>
</div>
<div class="content">
<p>Hi ${registration.name},</p>
<p>Thank you for registering for <strong>${eventData.title}</strong>!</p>
<div class="event-details">
<div class="detail-row">
<div class="label">Date</div>
<div class="value">${formatDate(eventData.startDate)}</div>
</div>
<div class="detail-row">
<div class="label">Time</div>
<div class="value">${formatTime(eventData.startDate, eventData.endDate)}</div>
</div>
<div class="detail-row">
<div class="label">Location</div>
<div class="value">${eventData.location}</div>
</div>
</div>
${eventData.description ? `<p>${eventData.description}</p>` : ""}
<center>
<a href="${process.env.BASE_URL || "https://ghostguild.org"}/events/${eventData.slug || eventData._id}" class="button">
View Event Details
</a>
</center>
<p style="margin-top: 30px; font-size: 14px; color: #666;">
<strong>Need to cancel?</strong><br>
Visit the event page and click "Cancel Registration" to remove yourself from the attendee list.
</p>
</div>
<div class="footer">
<p>Ghost Guild</p>
<p>
Questions? Email us at
<a href="mailto:events@babyghosts.org">events@babyghosts.org</a>
</p>
</div>
</body>
</html>
`,
});
if (error) {
console.error("Failed to send registration email:", error);
return { success: false, error };
}
return { success: true, data };
} catch (error) {
console.error("Error sending registration email:", error);
return { success: false, error };
}
}
/**
* Send event cancellation confirmation email
*/
export async function sendEventCancellationEmail(registration, eventData) {
try {
const { data, error } = await resend.emails.send({
from: "Ghost Guild <events@ghostguild.org>",
to: [registration.email],
subject: `Registration cancelled: ${eventData.title}`,
html: `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #1a1a2e;
color: #fff;
padding: 30px;
text-align: center;
border-radius: 8px 8px 0 0;
}
.content {
background-color: #f9f9f9;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.button {
display: inline-block;
background-color: #3b82f6;
color: #fff;
padding: 12px 30px;
text-decoration: none;
border-radius: 6px;
margin: 20px 0;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ddd;
color: #666;
font-size: 14px;
}
</style>
</head>
<body>
<div class="header">
<h1 style="margin: 0;">Registration Cancelled</h1>
</div>
<div class="content">
<p>Hi ${registration.name},</p>
<p>Your registration for <strong>${eventData.title}</strong> has been cancelled.</p>
<p>We're sorry you can't make it. You can always register again if your plans change.</p>
<center>
<a href="${process.env.BASE_URL || "https://ghostguild.org"}/events" class="button">
Browse Other Events
</a>
</center>
</div>
<div class="footer">
<p>Ghost Guild</p>
<p>
Questions? Email us at
<a href="mailto:events@babyghosts.org">events@babyghosts.org</a>
</p>
</div>
</body>
</html>
`,
});
if (error) {
console.error("Failed to send cancellation email:", error);
return { success: false, error };
}
return { success: true, data };
} catch (error) {
console.error("Error sending cancellation email:", error);
return { success: false, error };
}
}