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

View file

@ -1,68 +1,40 @@
// Central configuration for Ghost Guild Contribution Levels and Helcim Plans // Central configuration for Ghost Guild Contribution Levels and Helcim Plans
export const CONTRIBUTION_TIERS = { export const CONTRIBUTION_TIERS = {
FREE: { FREE: {
value: '0', value: "0",
amount: 0, amount: 0,
label: '$0 - I need support right now', label: "$0 - I need support right now",
tier: 'free', tier: "free",
helcimPlanId: null, // No Helcim plan needed for free tier helcimPlanId: null, // No Helcim plan needed for free tier
features: [
'Access to basic resources',
'Community forum access'
]
}, },
SUPPORTER: { SUPPORTER: {
value: '5', value: "5",
amount: 5, amount: 5,
label: '$5 - I can contribute a little', label: "$5 - I can contribute",
tier: 'supporter', tier: "supporter",
helcimPlanId: 'supporter-monthly-5', helcimPlanId: "supporter-monthly-5",
features: [
'All Free Membership benefits',
'Priority community support',
'Early access to events'
]
}, },
MEMBER: { MEMBER: {
value: '15', value: "15",
amount: 15, amount: 15,
label: '$15 - I can sustain the community', label: "$15 - I can sustain the community",
tier: 'member', tier: "member",
helcimPlanId: 'member-monthly-15', helcimPlanId: "member-monthly-15",
features: [
'All Supporter benefits',
'Access to premium workshops',
'Monthly 1-on-1 sessions',
'Advanced resource library'
]
}, },
ADVOCATE: { ADVOCATE: {
value: '30', value: "30",
amount: 30, amount: 30,
label: '$30 - I can support others too', label: "$30 - I can support others too",
tier: 'advocate', tier: "advocate",
helcimPlanId: 'advocate-monthly-30', helcimPlanId: "advocate-monthly-30",
features: [
'All Member benefits',
'Weekly group mentoring',
'Access to exclusive events',
'Direct messaging with experts'
]
}, },
CHAMPION: { CHAMPION: {
value: '50', value: "50",
amount: 50, amount: 50,
label: '$50 - I want to sponsor multiple members', label: "$50 - I want to sponsor multiple members",
tier: 'champion', tier: "champion",
helcimPlanId: 'champion-monthly-50', helcimPlanId: "champion-monthly-50",
features: [ },
'All Advocate benefits',
'Personal mentoring sessions',
'VIP event access',
'Custom project support',
'Annual strategy session'
]
}
}; };
// Get all contribution options as an array (useful for forms) // Get all contribution options as an array (useful for forms)
@ -72,12 +44,12 @@ export const getContributionOptions = () => {
// Get valid contribution values for validation // Get valid contribution values for validation
export const getValidContributionValues = () => { 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 // Get contribution tier by value
export const getContributionTierByValue = (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 // Get Helcim plan ID for a contribution tier
@ -99,10 +71,12 @@ export const isValidContributionValue = (value) => {
// Get contribution tier by Helcim plan ID // Get contribution tier by Helcim plan ID
export const getContributionTierByHelcimPlan = (helcimPlanId) => { 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) // Get paid tiers only (excluding free tier)
export const getPaidContributionTiers = () => { 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="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 justify-between items-center py-4">
<div class="flex items-center gap-8"> <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 Ghost Guild
</NuxtLink> </NuxtLink>
@ -15,13 +18,28 @@
:class="[ :class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200', 'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path === '/admin' $route.path === '/admin'
? 'bg-blue-100 text-blue-700 shadow-sm' ? 'bg-primary-100 text-primary-700 shadow-sm'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : '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"> <svg
<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"/> class="w-4 h-4 inline-block mr-2"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4h4"/> 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> </svg>
Dashboard Dashboard
</NuxtLink> </NuxtLink>
@ -31,12 +49,22 @@
:class="[ :class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200', 'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path.includes('/admin/members') $route.path.includes('/admin/members')
? 'bg-blue-100 text-blue-700 shadow-sm' ? 'bg-primary-100 text-primary-700 shadow-sm'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : '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"> <svg
<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"/> 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> </svg>
Members Members
</NuxtLink> </NuxtLink>
@ -46,12 +74,22 @@
:class="[ :class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200', 'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path.includes('/admin/events') $route.path.includes('/admin/events')
? 'bg-blue-100 text-blue-700 shadow-sm' ? 'bg-primary-100 text-primary-700 shadow-sm'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : '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"> <svg
<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"/> 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> </svg>
Events Events
</NuxtLink> </NuxtLink>
@ -61,12 +99,22 @@
:class="[ :class="[
'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200', 'px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200',
$route.path.includes('/admin/series') $route.path.includes('/admin/series')
? 'bg-blue-100 text-blue-700 shadow-sm' ? 'bg-primary-100 text-primary-700 shadow-sm'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : '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"> <svg
<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"/> 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> </svg>
Series Series
</NuxtLink> </NuxtLink>
@ -75,39 +123,121 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<!-- User Menu --> <!-- User Menu -->
<div class="relative" @click="showUserMenu = !showUserMenu" v-click-outside="() => showUserMenu = false"> <div
<button class="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 cursor-pointer transition-colors"> class="relative"
<div class="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center"> @click="showUserMenu = !showUserMenu"
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24"> v-click-outside="() => (showUserMenu = false)"
<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"/> >
<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> </svg>
</div> </div>
<span class="hidden md:block text-sm font-medium text-gray-700">Admin</span> <span class="hidden md:block text-sm font-medium text-gray-700"
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> >Admin</span
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/> >
<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> </svg>
</button> </button>
<!-- User Menu Dropdown --> <!-- 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"> <div
<NuxtLink to="/" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"> v-if="showUserMenu"
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="absolute right-0 mt-2 w-56 bg-white rounded-lg shadow-lg border border-gray-200 py-1 z-50"
<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"/> <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> </svg>
View Site View Site
</NuxtLink> </NuxtLink>
<NuxtLink to="/admin/settings" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"> <NuxtLink
<svg class="w-4 h-4 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> to="/admin/settings"
<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"/> class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/> >
<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> </svg>
Settings Settings
</NuxtLink> </NuxtLink>
<hr class="my-1 border-gray-200"> <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"> <button
<svg class="w-4 h-4 mr-3 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> @click="logout"
<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"/> 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> </svg>
Logout Logout
</button> </button>
@ -127,8 +257,8 @@
:class="[ :class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors', 'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path === '/admin' $route.path === '/admin'
? 'bg-blue-100 text-blue-700' ? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : 'text-gray-600 hover:text-primary hover:bg-primary-50',
]" ]"
> >
Dashboard Dashboard
@ -139,8 +269,8 @@
:class="[ :class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors', 'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path.includes('/admin/members') $route.path.includes('/admin/members')
? 'bg-blue-100 text-blue-700' ? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : 'text-gray-600 hover:text-primary hover:bg-primary-50',
]" ]"
> >
Members Members
@ -151,8 +281,8 @@
:class="[ :class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors', 'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path.includes('/admin/events') $route.path.includes('/admin/events')
? 'bg-blue-100 text-blue-700' ? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : 'text-gray-600 hover:text-primary hover:bg-primary-50',
]" ]"
> >
Events Events
@ -163,8 +293,8 @@
:class="[ :class="[
'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors', 'px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors',
$route.path.includes('/admin/series') $route.path.includes('/admin/series')
? 'bg-blue-100 text-blue-700' ? 'bg-primary-100 text-primary-700'
: 'text-gray-600 hover:text-blue-600 hover:bg-blue-50' : 'text-gray-600 hover:text-primary hover:bg-primary-50',
]" ]"
> >
Series Series
@ -192,29 +322,29 @@
</template> </template>
<script setup> <script setup>
const showUserMenu = ref(false) const showUserMenu = ref(false);
// Close user menu when clicking outside // Close user menu when clicking outside
const vClickOutside = { const vClickOutside = {
beforeMount(el, binding) { beforeMount(el, binding) {
el.clickOutsideEvent = (event) => { el.clickOutsideEvent = (event) => {
if (!(el === event.target || el.contains(event.target))) { if (!(el === event.target || el.contains(event.target))) {
binding.value() binding.value();
} }
} };
document.addEventListener('click', el.clickOutsideEvent) document.addEventListener("click", el.clickOutsideEvent);
}, },
unmounted(el) { unmounted(el) {
document.removeEventListener('click', el.clickOutsideEvent) document.removeEventListener("click", el.clickOutsideEvent);
} },
} };
const logout = async () => { const logout = async () => {
try { try {
await $fetch('/api/auth/logout', { method: 'POST' }) await $fetch("/api/auth/logout", { method: "POST" });
await navigateTo('/login') await navigateTo("/login");
} catch (error) { } catch (error) {
console.error('Logout failed:', error) console.error("Logout failed:", error);
}
} }
};
</script> </script>

View file

@ -24,24 +24,15 @@
</p> </p>
<ul <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> <li>
<strong>Equal access:</strong> The entire knowledge commons, all The entire knowledge commons, all events, and full community
events, and full community participation participation on our private Slack
</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
</li> </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> </ul>
</div> </div>
</div> </div>
@ -53,185 +44,129 @@
<UContainer> <UContainer>
<div class="max-w-4xl mx-auto"> <div class="max-w-4xl mx-auto">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4"> <h2 class="text-3xl font-bold text-[--ui-text] mb-4">
Find Your Circle Find your circle
</h2> </h2>
<p class="text-lg text-[--ui-text-muted] mb-12"> <p class="text-lg text-[--ui-text-muted] mb-12">
Circles help us provide relevant guidance and connect you with Circles help us provide relevant guidance and connect you with
others at similar stages. Choose based on where you are, not what others at similar stages. Choose based on where you are now!
you want to access.
</p> </p>
<div class="space-y-12"> <div class="space-y-12">
<!-- Community Circle --> <!-- Community Circle -->
<div class="bg-[--ui-bg] rounded-xl p-8"> <div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2"> <h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Community Circle Community Circle
</h3> </h3>
<p class="text-lg text-[--ui-text-muted] mb-6">
You're exploring what cooperatives could mean for your work <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> </p>
<div class="mb-6"> <p>
<h4 class="text-lg font-semibold text-[--ui-text] mb-3"> This space is for you if you're: an individual game worker
Where you might be: dreaming of different possibilities a researcher digging
</h4> into the rise of alternative studio models an industry ally
<ul class="text-[--ui-text-muted] space-y-2"> who wants to support cooperative work <em>anyone</em> who's
<li> co-op-curious!
Curious about alternatives to traditional studio structures </p>
</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="mb-6"> <p>
<h4 class="text-lg font-semibold text-[--ui-text] mb-3"> Our resources and community space will help you understand
We'll help you navigate: cooperative basics, connect with others asking the same
</h4> questions, and give you a look at real examples from game
<ul class="text-[--ui-text-muted] space-y-2"> studios. You don't need to have a studio or project of your
<li>Understanding cooperative basics</li> own - just join and see what strikes your fancy!
<li>Connecting with others asking similar questions</li> </p>
<li>Exploring real examples from game studios</li>
<li>Deciding your next steps</li>
</ul>
</div>
<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>
</div> </div>
</div> </div>
<!-- Founder Circle --> <!-- Founder Circle -->
<div class="bg-[--ui-bg] rounded-xl p-8"> <div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2"> <h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Founder Circle Founder Circle
</h3> </h3>
<p class="text-lg text-[--ui-text-muted] mb-6">
You're actively building or transitioning to a cooperative model <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> </p>
<div class="mb-6"> <p>
<h4 This is the space for the practical stuff: governance
class="text-lg font-semibold text-gray-900 dark:text-white mb-3" documents you can read and adapt, financial models for
> cooperative studios, connections with other founders
Where you might be: navigating similar challenges.
</h4> </p>
<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="mb-6"> <p>
<h4 We have two paths through this circle that we will be
class="text-lg font-semibold text-gray-900 dark:text-white mb-3" launching soon:
> </p>
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>
<div class="mb-6"> <ul>
<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">
<li> <li>
<strong>Peer Accelerator Prep Track:</strong> Structured Peer Accelerator Prep Track <em>(coming soon)</em>
preparation for the PA program Structured preparation if you're planning to apply for the
PA program
</li> </li>
<li> <li>
<strong>Indie Track:</strong> Self-paced development for Indie Track <em>(coming soon)</em> Flexible, self-paced
alternative pathways support for teams building at their own pace
</li> </li>
</ul> </ul>
</div>
<div> <p>
<h4 Join us to figure out how you can balance your values with
class="text-lg font-semibold text-gray-900 dark:text-white mb-3" keeping the lights on - whether you're a full founding team, a
> solo founder exploring structures, or an existing studio in
You might be: transition.
</h4> </p>
<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>
</div> </div>
</div> </div>
<!-- Practitioner Circle --> <!-- Practitioner Circle -->
<div class="bg-[--ui-bg] rounded-xl p-8"> <div class="">
<h3 class="text-2xl font-bold text-[--ui-text] mb-2"> <h3 class="text-2xl font-bold text-[--ui-text] mb-2">
Practitioner Circle Practitioner Circle
</h3> </h3>
<p class="text-lg text-[--ui-text-muted] mb-6">
You're operating a cooperative and contributing to the field <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> </p>
<div class="mb-6"> <p>
<h4 This circle is for: Peer Accelerator alumni members of
class="text-lg font-semibold text-gray-900 dark:text-white mb-3" established co-ops mentors who want to support other
> cooperatives researchers studying cooperative models in
Where you might be: practice
</h4> </p>
<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="mb-6"> <p>
<h4 Here, we create space for practitioners to share what's
class="text-lg font-semibold text-gray-900 dark:text-white mb-3" actually working (and what isn't), support emerging
> cooperatives, collaborate across studios, and contribute to
We'll help you navigate: building a knowledge commons.
</h4> </p>
<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>
<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>
</div> </div>
</div> </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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6"> <div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Admin Dashboard</h1> <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> </div>
</div> </div>
@ -20,9 +22,21 @@
{{ stats.totalMembers || 0 }} {{ stats.totalMembers || 0 }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center"> <div
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center"
<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
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> </svg>
</div> </div>
</div> </div>
@ -36,9 +50,21 @@
{{ stats.activeEvents || 0 }} {{ stats.activeEvents || 0 }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center"> <div
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center"
<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
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> </svg>
</div> </div>
</div> </div>
@ -52,9 +78,21 @@
${{ stats.monthlyRevenue || 0 }} ${{ stats.monthlyRevenue || 0 }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center"> <div
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center"
<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
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> </svg>
</div> </div>
</div> </div>
@ -68,9 +106,21 @@
{{ stats.pendingSlackInvites || 0 }} {{ stats.pendingSlackInvites || 0 }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center"> <div
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center"
<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
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> </svg>
</div> </div>
</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="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="bg-white rounded-lg shadow p-6">
<div class="text-center"> <div class="text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4"> <div
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
<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
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> </svg>
</div> </div>
<h3 class="text-lg font-semibold mb-2">Add New Member</h3> <h3 class="text-lg font-semibold mb-2">Add New Member</h3>
<p class="text-gray-600 text-sm mb-4"> <p class="text-gray-600 text-sm mb-4">
Add a new member to the Ghost Guild community Add a new member to the Ghost Guild community
</p> </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 Manage Members
</button> </button>
</div> </div>
@ -98,16 +163,31 @@
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="text-center"> <div class="text-center">
<div class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4"> <div
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path> >
<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> </svg>
</div> </div>
<h3 class="text-lg font-semibold mb-2">Create Event</h3> <h3 class="text-lg font-semibold mb-2">Create Event</h3>
<p class="text-gray-600 text-sm mb-4"> <p class="text-gray-600 text-sm mb-4">
Schedule a new community event or workshop Schedule a new community event or workshop
</p> </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 Manage Events
</button> </button>
</div> </div>
@ -115,16 +195,31 @@
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="text-center"> <div class="text-center">
<div class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4"> <div
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
<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
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> </svg>
</div> </div>
<h3 class="text-lg font-semibold mb-2">View Analytics</h3> <h3 class="text-lg font-semibold mb-2">View Analytics</h3>
<p class="text-gray-600 text-sm mb-4"> <p class="text-gray-600 text-sm mb-4">
Review member engagement and growth metrics Review member engagement and growth metrics
</p> </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 Coming Soon
</button> </button>
</div> </div>
@ -137,7 +232,10 @@
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Recent Members</h3> <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 View All
</button> </button>
</div> </div>
@ -145,19 +243,30 @@
<div class="p-6"> <div class="p-6">
<div v-if="pending" class="text-center py-4"> <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>
<div v-else-if="recentMembers.length" class="space-y-3"> <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> <div>
<p class="font-medium">{{ member.name }}</p> <p class="font-medium">{{ member.name }}</p>
<p class="text-sm text-gray-600">{{ member.email }}</p> <p class="text-sm text-gray-600">{{ member.email }}</p>
</div> </div>
<div class="text-right"> <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 }} {{ member.circle }}
</span> </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> </div>
</div> </div>
@ -171,7 +280,10 @@
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Upcoming Events</h3> <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 View All
</button> </button>
</div> </div>
@ -179,19 +291,32 @@
<div class="p-6"> <div class="p-6">
<div v-if="pending" class="text-center py-4"> <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>
<div v-else-if="upcomingEvents.length" class="space-y-3"> <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> <div>
<p class="font-medium">{{ event.title }}</p> <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>
<div class="text-right"> <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 }} {{ event.eventType }}
</span> </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> </div>
</div> </div>
@ -207,44 +332,46 @@
<script setup> <script setup>
definePageMeta({ 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 stats = computed(() => dashboardData.value?.stats || {});
const recentMembers = computed(() => dashboardData.value?.recentMembers || []) const recentMembers = computed(() => dashboardData.value?.recentMembers || []);
const upcomingEvents = computed(() => dashboardData.value?.upcomingEvents || []) const upcomingEvents = computed(
() => dashboardData.value?.upcomingEvents || [],
);
const getCircleBadgeClasses = (circle) => { const getCircleBadgeClasses = (circle) => {
const classes = { const classes = {
community: 'bg-blue-100 text-blue-800', community: "bg-blue-100 text-blue-800",
founder: 'bg-purple-100 text-purple-800', founder: "bg-purple-100 text-purple-800",
practitioner: 'bg-green-100 text-green-800' practitioner: "bg-green-100 text-green-800",
} };
return classes[circle] || 'bg-gray-100 text-gray-800' return classes[circle] || "bg-gray-100 text-gray-800";
} };
const getEventTypeBadgeClasses = (type) => { const getEventTypeBadgeClasses = (type) => {
const classes = { const classes = {
community: 'bg-blue-100 text-blue-800', community: "bg-blue-100 text-blue-800",
workshop: 'bg-green-100 text-green-800', workshop: "bg-green-100 text-green-800",
social: 'bg-purple-100 text-purple-800', social: "bg-purple-100 text-purple-800",
showcase: 'bg-orange-100 text-orange-800' showcase: "bg-orange-100 text-orange-800",
} };
return classes[type] || 'bg-gray-100 text-gray-800' return classes[type] || "bg-gray-100 text-gray-800";
} };
const formatDate = (dateString) => { const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString() return new Date(dateString).toLocaleDateString();
} };
const formatDateTime = (dateString) => { const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleDateString(undefined, { return new Date(dateString).toLocaleDateString(undefined, {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
hour: 'numeric', hour: "numeric",
minute: '2-digit' minute: "2-digit",
}) });
} };
</script> </script>

View file

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

View file

@ -4,7 +4,9 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6"> <div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Admin Dashboard</h1> <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> </div>
</div> </div>
@ -20,9 +22,21 @@
{{ stats.totalMembers || 0 }} {{ stats.totalMembers || 0 }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center"> <div
<svg class="w-6 h-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-12 h-12 bg-blue-100 rounded-xl flex items-center justify-center"
<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
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> </svg>
</div> </div>
</div> </div>
@ -36,9 +50,21 @@
{{ stats.activeEvents || 0 }} {{ stats.activeEvents || 0 }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center"> <div
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-12 h-12 bg-green-100 rounded-xl flex items-center justify-center"
<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
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> </svg>
</div> </div>
</div> </div>
@ -52,9 +78,21 @@
${{ stats.monthlyRevenue || 0 }} ${{ stats.monthlyRevenue || 0 }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center"> <div
<svg class="w-6 h-6 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-12 h-12 bg-purple-100 rounded-xl flex items-center justify-center"
<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
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> </svg>
</div> </div>
</div> </div>
@ -68,9 +106,21 @@
{{ stats.pendingSlackInvites || 0 }} {{ stats.pendingSlackInvites || 0 }}
</p> </p>
</div> </div>
<div class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center"> <div
<svg class="w-6 h-6 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-12 h-12 bg-orange-100 rounded-xl flex items-center justify-center"
<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
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> </svg>
</div> </div>
</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="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="bg-white rounded-lg shadow p-6">
<div class="text-center"> <div class="text-center">
<div class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4"> <div
<svg class="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
<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
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> </svg>
</div> </div>
<h3 class="text-lg font-semibold mb-2">Add New Member</h3> <h3 class="text-lg font-semibold mb-2">Add New Member</h3>
<p class="text-gray-600 text-sm mb-4"> <p class="text-gray-600 text-sm mb-4">
Add a new member to the Ghost Guild community Add a new member to the Ghost Guild community
</p> </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 Manage Members
</button> </button>
</div> </div>
@ -98,16 +163,31 @@
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="text-center"> <div class="text-center">
<div class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4"> <div
<svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-16 h-16 bg-green-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path> >
<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> </svg>
</div> </div>
<h3 class="text-lg font-semibold mb-2">Create Event</h3> <h3 class="text-lg font-semibold mb-2">Create Event</h3>
<p class="text-gray-600 text-sm mb-4"> <p class="text-gray-600 text-sm mb-4">
Schedule a new community event or workshop Schedule a new community event or workshop
</p> </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 Manage Events
</button> </button>
</div> </div>
@ -115,16 +195,31 @@
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="text-center"> <div class="text-center">
<div class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4"> <div
<svg class="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> class="w-16 h-16 bg-purple-100 rounded-2xl flex items-center justify-center mx-auto mb-4"
<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
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> </svg>
</div> </div>
<h3 class="text-lg font-semibold mb-2">View Analytics</h3> <h3 class="text-lg font-semibold mb-2">View Analytics</h3>
<p class="text-gray-600 text-sm mb-4"> <p class="text-gray-600 text-sm mb-4">
Review member engagement and growth metrics Review member engagement and growth metrics
</p> </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 Coming Soon
</button> </button>
</div> </div>
@ -137,7 +232,10 @@
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Recent Members</h3> <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 View All
</button> </button>
</div> </div>
@ -145,19 +243,30 @@
<div class="p-6"> <div class="p-6">
<div v-if="pending" class="text-center py-4"> <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>
<div v-else-if="recentMembers.length" class="space-y-3"> <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> <div>
<p class="font-medium">{{ member.name }}</p> <p class="font-medium">{{ member.name }}</p>
<p class="text-sm text-gray-600">{{ member.email }}</p> <p class="text-sm text-gray-600">{{ member.email }}</p>
</div> </div>
<div class="text-right"> <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 }} {{ member.circle }}
</span> </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> </div>
</div> </div>
@ -171,7 +280,10 @@
<div class="px-6 py-4 border-b border-gray-200"> <div class="px-6 py-4 border-b border-gray-200">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<h3 class="text-lg font-semibold">Upcoming Events</h3> <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 View All
</button> </button>
</div> </div>
@ -179,19 +291,32 @@
<div class="p-6"> <div class="p-6">
<div v-if="pending" class="text-center py-4"> <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>
<div v-else-if="upcomingEvents.length" class="space-y-3"> <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> <div>
<p class="font-medium">{{ event.title }}</p> <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>
<div class="text-right"> <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 }} {{ event.eventType }}
</span> </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> </div>
</div> </div>
@ -207,44 +332,46 @@
<script setup> <script setup>
definePageMeta({ 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 stats = computed(() => dashboardData.value?.stats || {});
const recentMembers = computed(() => dashboardData.value?.recentMembers || []) const recentMembers = computed(() => dashboardData.value?.recentMembers || []);
const upcomingEvents = computed(() => dashboardData.value?.upcomingEvents || []) const upcomingEvents = computed(
() => dashboardData.value?.upcomingEvents || [],
);
const getCircleBadgeClasses = (circle) => { const getCircleBadgeClasses = (circle) => {
const classes = { const classes = {
community: 'bg-blue-100 text-blue-800', community: "bg-blue-100 text-blue-800",
founder: 'bg-purple-100 text-purple-800', founder: "bg-purple-100 text-purple-800",
practitioner: 'bg-green-100 text-green-800' practitioner: "bg-green-100 text-green-800",
} };
return classes[circle] || 'bg-gray-100 text-gray-800' return classes[circle] || "bg-gray-100 text-gray-800";
} };
const getEventTypeBadgeClasses = (type) => { const getEventTypeBadgeClasses = (type) => {
const classes = { const classes = {
community: 'bg-blue-100 text-blue-800', community: "bg-blue-100 text-blue-800",
workshop: 'bg-green-100 text-green-800', workshop: "bg-green-100 text-green-800",
social: 'bg-purple-100 text-purple-800', social: "bg-purple-100 text-purple-800",
showcase: 'bg-orange-100 text-orange-800' showcase: "bg-orange-100 text-orange-800",
} };
return classes[type] || 'bg-gray-100 text-gray-800' return classes[type] || "bg-gray-100 text-gray-800";
} };
const formatDate = (dateString) => { const formatDate = (dateString) => {
return new Date(dateString).toLocaleDateString() return new Date(dateString).toLocaleDateString();
} };
const formatDateTime = (dateString) => { const formatDateTime = (dateString) => {
return new Date(dateString).toLocaleDateString(undefined, { return new Date(dateString).toLocaleDateString(undefined, {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
hour: 'numeric', hour: "numeric",
minute: '2-digit' minute: "2-digit",
}) });
} };
</script> </script>

View file

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

View file

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

View file

@ -4,7 +4,9 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="py-6"> <div class="py-6">
<h1 class="text-2xl font-bold text-gray-900">Series Management</h1> <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> </div>
</div> </div>
@ -16,34 +18,51 @@
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center"> <div class="flex items-center">
<div class="p-3 bg-purple-100 rounded-full"> <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>
<div class="ml-4"> <div class="ml-4">
<p class="text-sm text-gray-500">Active Series</p> <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>
</div> </div>
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center"> <div class="flex items-center">
<div class="p-3 bg-blue-100 rounded-full"> <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>
<div class="ml-4"> <div class="ml-4">
<p class="text-sm text-gray-500">Total Series Events</p> <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>
</div> </div>
<div class="bg-white rounded-lg shadow p-6"> <div class="bg-white rounded-lg shadow p-6">
<div class="flex items-center"> <div class="flex items-center">
<div class="p-3 bg-green-100 rounded-full"> <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>
<div class="ml-4"> <div class="ml-4">
<p class="text-sm text-gray-500">Avg Events/Series</p> <p class="text-sm text-gray-500">Avg Events/Series</p>
<p class="text-2xl font-semibold text-gray-900"> <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> </p>
</div> </div>
</div> </div>
@ -89,7 +108,9 @@
<!-- Series List --> <!-- Series List -->
<div v-if="pending" class="text-center py-12"> <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> <p class="text-gray-600">Loading series...</p>
</div> </div>
@ -103,24 +124,32 @@
<div class="px-6 py-4 bg-gray-50 border-b border-gray-200"> <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 justify-between">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div :class="[ <div
:class="[
'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium', 'inline-flex items-center px-3 py-1 rounded-full text-xs font-medium',
getSeriesTypeBadgeClass(series.type) getSeriesTypeBadgeClass(series.type),
]"> ]"
>
{{ formatSeriesType(series.type) }} {{ formatSeriesType(series.type) }}
</div> </div>
<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> <p class="text-sm text-gray-600">{{ series.description }}</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<span :class="[ <span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium', '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 === 'active'
series.status === 'upcoming' ? 'bg-blue-100 text-blue-700' : ? 'bg-green-100 text-green-700'
'bg-gray-100 text-gray-700' : series.status === 'upcoming'
]"> ? 'bg-blue-100 text-blue-700'
: 'bg-gray-100 text-gray-700',
]"
>
{{ series.status }} {{ series.status }}
</span> </span>
<span class="text-sm text-gray-500"> <span class="text-sm text-gray-500">
@ -139,19 +168,27 @@
> >
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex items-center gap-4"> <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"> <div
{{ event.series?.position || '?' }} 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>
<div> <div>
<h4 class="text-sm font-medium text-gray-900">{{ event.title }}</h4> <h4 class="text-sm font-medium text-gray-900">
<p class="text-xs text-gray-500">{{ formatEventDate(event.startDate) }}</p> {{ event.title }}
</h4>
<p class="text-xs text-gray-500">
{{ formatEventDate(event.startDate) }}
</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span :class="[ <span
:class="[
'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium', 'inline-flex items-center px-2 py-1 rounded-full text-xs font-medium',
getEventStatusClass(event) getEventStatusClass(event),
]"> ]"
>
{{ getEventStatus(event) }} {{ getEventStatus(event) }}
</span> </span>
<div class="flex gap-1"> <div class="flex gap-1">
@ -164,7 +201,7 @@
</NuxtLink> </NuxtLink>
<button <button
@click="editEvent(event)" @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" title="Edit Event"
> >
<Icon name="heroicons:pencil-square" class="w-4 h-4" /> <Icon name="heroicons:pencil-square" class="w-4 h-4" />
@ -189,15 +226,21 @@
{{ formatDateRange(series.startDate, series.endDate) }} {{ formatDateRange(series.startDate, series.endDate) }}
</div> </div>
<div class="flex gap-2"> <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 <button
@click="addEventToSeries(series)" @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 Add Event
</button> </button>
<button <button
@click="duplicateSeries(series)" @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 Duplicate Series
</button> </button>
@ -214,19 +257,32 @@
</div> </div>
<div v-else class="text-center py-12 bg-white rounded-lg shadow"> <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-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>
</div> </div>
<!-- Bulk Operations Modal --> <!-- Edit Series 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
<div class="bg-white rounded-lg shadow-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto"> 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="px-6 py-4 border-b border-gray-200">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900">Bulk Series Operations</h3> <h3 class="text-lg font-semibold text-gray-900">Edit Series</h3>
<button @click="showBulkModal = false" class="text-gray-400 hover:text-gray-600"> <button
@click="cancelEditSeries"
class="text-gray-400 hover:text-gray-600"
>
<Icon name="heroicons:x-mark" class="w-5 h-5" /> <Icon name="heroicons:x-mark" class="w-5 h-5" />
</button> </button>
</div> </div>
@ -234,17 +290,119 @@
<div class="p-6 space-y-6"> <div class="p-6 space-y-6">
<div> <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"> <div class="space-y-3">
<button <button
@click="reorderAllSeries" @click="reorderAllSeries"
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50" class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
> >
<div class="flex items-center"> <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> <div>
<p class="text-sm font-medium text-gray-900">Auto-Reorder Series</p> <p class="text-sm font-medium text-gray-900">
<p class="text-xs text-gray-500">Fix position numbers based on event dates</p> Auto-Reorder Series
</p>
<p class="text-xs text-gray-500">
Fix position numbers based on event dates
</p>
</div> </div>
</div> </div>
</button> </button>
@ -254,10 +412,17 @@
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50" class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
> >
<div class="flex items-center"> <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> <div>
<p class="text-sm font-medium text-gray-900">Validate Series Data</p> <p class="text-sm font-medium text-gray-900">
<p class="text-xs text-gray-500">Check for consistency issues</p> Validate Series Data
</p>
<p class="text-xs text-gray-500">
Check for consistency issues
</p>
</div> </div>
</div> </div>
</button> </button>
@ -267,10 +432,17 @@
class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50" class="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50"
> >
<div class="flex items-center"> <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> <div>
<p class="text-sm font-medium text-gray-900">Export Series Data</p> <p class="text-sm font-medium text-gray-900">
<p class="text-xs text-gray-500">Download series information as JSON</p> Export Series Data
</p>
<p class="text-xs text-gray-500">
Download series information as JSON
</p>
</div> </div>
</div> </div>
</button> </button>
@ -293,136 +465,152 @@
<script setup> <script setup>
definePageMeta({ definePageMeta({
layout: 'admin' layout: "admin",
}) });
const showBulkModal = ref(false) const showBulkModal = ref(false);
const searchQuery = ref('') const searchQuery = ref("");
const statusFilter = ref('') const statusFilter = ref("");
const editingSeriesId = ref(null);
const editingSeriesData = ref({
title: "",
description: "",
type: "workshop_series",
totalEvents: null,
});
// Fetch series data // 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 // Computed properties
const activeSeries = computed(() => { const activeSeries = computed(() => {
if (!seriesData.value) return [] if (!seriesData.value) return [];
return seriesData.value return seriesData.value;
}) });
const totalSeriesEvents = computed(() => { 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(() => { const filteredSeries = computed(() => {
if (!activeSeries.value) return [] if (!activeSeries.value) return [];
return activeSeries.value.filter(series => { return activeSeries.value.filter((series) => {
const matchesSearch = !searchQuery.value || const matchesSearch =
!searchQuery.value ||
series.title.toLowerCase().includes(searchQuery.value.toLowerCase()) || 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 // Helper functions
const formatSeriesType = (type) => { const formatSeriesType = (type) => {
const types = { const types = {
'workshop_series': 'Workshop Series', workshop_series: "Workshop Series",
'recurring_meetup': 'Recurring Meetup', recurring_meetup: "Recurring Meetup",
'multi_day': 'Multi-Day Event', multi_day: "Multi-Day Event",
'course': 'Course', course: "Course",
'tournament': 'Tournament' };
} return types[type] || type;
return types[type] || type };
}
const getSeriesTypeBadgeClass = (type) => { const getSeriesTypeBadgeClass = (type) => {
const classes = { const classes = {
'workshop_series': 'bg-emerald-100 text-emerald-700', workshop_series: "bg-emerald-100 text-emerald-700",
'recurring_meetup': 'bg-blue-100 text-blue-700', recurring_meetup: "bg-blue-100 text-blue-700",
'multi_day': 'bg-purple-100 text-purple-700', multi_day: "bg-purple-100 text-purple-700",
'course': 'bg-amber-100 text-amber-700', course: "bg-amber-100 text-amber-700",
'tournament': 'bg-red-100 text-red-700' };
} return classes[type] || "bg-gray-100 text-gray-700";
return classes[type] || 'bg-gray-100 text-gray-700' };
}
const formatEventDate = (date) => { const formatEventDate = (date) => {
return new Date(date).toLocaleDateString('en-US', { return new Date(date).toLocaleDateString("en-US", {
month: 'short', month: "short",
day: 'numeric', day: "numeric",
year: 'numeric' year: "numeric",
}) });
} };
const formatDateRange = (startDate, endDate) => { const formatDateRange = (startDate, endDate) => {
if (!startDate || !endDate) return 'No dates' if (!startDate || !endDate) return "No dates";
const start = new Date(startDate) const start = new Date(startDate);
const end = new Date(endDate) const end = new Date(endDate);
const formatter = new Intl.DateTimeFormat('en-US', { const formatter = new Intl.DateTimeFormat("en-US", {
month: 'short', month: "short",
day: 'numeric' day: "numeric",
}) });
return `${formatter.format(start)} - ${formatter.format(end)}` return `${formatter.format(start)} - ${formatter.format(end)}`;
} };
const getEventStatus = (event) => { const getEventStatus = (event) => {
const now = new Date() const now = new Date();
const startDate = new Date(event.startDate) const startDate = new Date(event.startDate);
const endDate = new Date(event.endDate) const endDate = new Date(event.endDate);
if (now < startDate) return 'Upcoming' if (now < startDate) return "Upcoming";
if (now >= startDate && now <= endDate) return 'Ongoing' if (now >= startDate && now <= endDate) return "Ongoing";
return 'Completed' return "Completed";
} };
const getEventStatusClass = (event) => { const getEventStatusClass = (event) => {
const status = getEventStatus(event) const status = getEventStatus(event);
const classes = { const classes = {
'Upcoming': 'bg-blue-100 text-blue-700', Upcoming: "bg-blue-100 text-blue-700",
'Ongoing': 'bg-green-100 text-green-700', Ongoing: "bg-green-100 text-green-700",
'Completed': 'bg-gray-100 text-gray-700' Completed: "bg-gray-100 text-gray-700",
} };
return classes[status] || 'bg-gray-100 text-gray-700' return classes[status] || "bg-gray-100 text-gray-700";
} };
// Actions // Actions
const editEvent = (event) => { const editEvent = (event) => {
navigateTo(`/admin/events/create?edit=${event.id}`) navigateTo(`/admin/events/create?edit=${event.id}`);
} };
const removeFromSeries = async (event) => { const removeFromSeries = async (event) => {
if (!confirm(`Remove "${event.title}" from its series?`)) return if (!confirm(`Remove "${event.title}" from its series?`)) return;
try { try {
await $fetch(`/api/admin/events/${event.id}`, { await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT', method: "PUT",
body: { body: {
...event, ...event,
series: { series: {
isSeriesEvent: false, isSeriesEvent: false,
id: '', id: "",
title: '', title: "",
description: '', description: "",
type: 'workshop_series', type: "workshop_series",
position: 1, position: 1,
totalEvents: null totalEvents: null,
} },
} },
}) });
await refresh() await refresh();
} catch (error) { } catch (error) {
console.error('Failed to remove event from series:', error) console.error("Failed to remove event from series:", error);
alert('Failed to remove event from series') alert("Failed to remove event from series");
}
} }
};
const addEventToSeries = (series) => { const addEventToSeries = (series) => {
// Navigate to create page with series pre-filled // Navigate to create page with series pre-filled
@ -434,71 +622,121 @@ const addEventToSeries = (series) => {
description: series.description, description: series.description,
type: series.type, type: series.type,
position: (series.eventCount || 0) + 1, position: (series.eventCount || 0) + 1,
totalEvents: series.totalEvents totalEvents: series.totalEvents,
} },
} };
sessionStorage.setItem('seriesEventData', JSON.stringify(seriesData)) sessionStorage.setItem("seriesEventData", JSON.stringify(seriesData));
navigateTo('/admin/events/create?series=true') navigateTo("/admin/events/create?series=true");
} };
const duplicateSeries = (series) => { const duplicateSeries = (series) => {
// TODO: Implement series duplication // 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) => { 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 { try {
// Update all events to remove series relationship // Update all events to remove series relationship
for (const event of series.events) { for (const event of series.events) {
await $fetch(`/api/admin/events/${event.id}`, { await $fetch(`/api/admin/events/${event.id}`, {
method: 'PUT', method: "PUT",
body: { body: {
...event, ...event,
series: { series: {
isSeriesEvent: false, isSeriesEvent: false,
id: '', id: "",
title: '', title: "",
description: '', description: "",
type: 'workshop_series', type: "workshop_series",
position: 1, position: 1,
totalEvents: null totalEvents: null,
} },
} },
}) });
} }
await refresh() await refresh();
alert('Series deleted and events converted to standalone events') alert("Series deleted and events converted to standalone events");
} catch (error) { } catch (error) {
console.error('Failed to delete series:', error) console.error("Failed to delete series:", error);
alert('Failed to delete series') alert("Failed to delete series");
}
} }
};
// Bulk operations // Bulk operations
const reorderAllSeries = async () => { const reorderAllSeries = async () => {
// TODO: Implement auto-reordering // TODO: Implement auto-reordering
alert('Auto-reorder feature coming soon!') alert("Auto-reorder feature coming soon!");
} };
const validateAllSeries = async () => { const validateAllSeries = async () => {
// TODO: Implement validation // TODO: Implement validation
alert('Validation feature coming soon!') alert("Validation feature coming soon!");
} };
const exportSeriesData = () => { const exportSeriesData = () => {
const dataStr = JSON.stringify(activeSeries.value, null, 2) const dataStr = JSON.stringify(activeSeries.value, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' }) const dataBlob = new Blob([dataStr], { type: "application/json" });
const url = URL.createObjectURL(dataBlob) const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a') const link = document.createElement("a");
link.href = url link.href = url;
link.download = 'event-series-data.json' link.download = "event-series-data.json";
document.body.appendChild(link) document.body.appendChild(link);
link.click() link.click();
document.body.removeChild(link) document.body.removeChild(link);
URL.revokeObjectURL(url) URL.revokeObjectURL(url);
} };
</script> </script>

View file

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

View file

@ -42,7 +42,7 @@
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-start gap-2 mb-1"> <div class="flex items-start gap-2 mb-1">
<h3 <h3
class="text-lg font-semibold text-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 }} {{ event.title }}
</h3> </h3>
@ -72,7 +72,7 @@
<Icon <Icon
name="heroicons:arrow-right" 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> </NuxtLink>
</div> </div>
@ -128,23 +128,15 @@
</section> </section>
<!-- Event Series --> <!-- Event Series -->
<section <div v-if="activeSeries.length > 0" class="text-center mb-12">
v-if="activeSeries.length > 0"
class="py-20 bg-ghost-800 dark:bg-ghost-900"
>
<UContainer>
<div class="text-center mb-12">
<h2 class="text-3xl font-bold text-ghost-100 mb-8"> <h2 class="text-3xl font-bold text-ghost-100 mb-8">
Active Event Series Current Event Series
</h2> </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>
<div <div
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-6xl mx-auto" v-if="activeSeries.length > 0"
class="space-y-6 max-w-6xl mx-auto mb-20"
> >
<div <div
v-for="series in activeSeries.slice(0, 6)" v-for="series in activeSeries.slice(0, 6)"
@ -176,7 +168,7 @@
<div class="space-y-2 mb-4"> <div class="space-y-2 mb-4">
<div <div
v-for="event in series.events.slice(0, 3)" v-for="(event, index) in series.events.slice(0, 3)"
:key="event.id" :key="event.id"
class="flex items-center justify-between text-xs" class="flex items-center justify-between text-xs"
> >
@ -184,7 +176,7 @@
<div <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" 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 || "?" }} {{ event.series?.position || index + 1 }}
</div> </div>
<span class="text-ghost-300 truncate">{{ event.title }}</span> <span class="text-ghost-300 truncate">{{ event.title }}</span>
</div> </div>
@ -219,8 +211,6 @@
</div> </div>
</div> </div>
</div> </div>
</UContainer>
</section>
<!-- Attend Our Events --> <!-- Attend Our Events -->
<section class="py-20 bg-ghost-800 dark:bg-ghost-900"> <section class="py-20 bg-ghost-800 dark:bg-ghost-900">
@ -354,11 +344,12 @@ const upcomingEvents = computed(() => {
// Format event date for display // Format event date for display
const formatEventDate = (date) => { const formatEventDate = (date) => {
const dateObj = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat("en-US", { return new Intl.DateTimeFormat("en-US", {
month: "short", month: "short",
day: "numeric", day: "numeric",
year: "numeric", year: "numeric",
}).format(date); }).format(dateObj);
}; };
// Get optimized Cloudinary image URL // Get optimized Cloudinary image URL

View file

@ -1,15 +1,23 @@
<template> <template>
<div> <div>
<!-- Page Header --> <!-- Page Header - Context aware -->
<PageHeader <PageHeader
v-if="!isAuthenticated"
title="Join Ghost Guild" title="Join Ghost Guild"
subtitle="Become a member of our community and start building a more worker-centric future for games." subtitle="Become a member of our community and start building a more worker-centric future for games."
theme="gray" theme="gray"
size="large" 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 --> <!-- 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"> <UContainer class="max-w-4xl">
<div class="text-center mb-12"> <div class="text-center mb-12">
<h2 class="text-3xl font-bold text-[--ui-text] mb-4"> <h2 class="text-3xl font-bold text-[--ui-text] mb-4">
@ -325,6 +333,59 @@
</UContainer> </UContainer>
</section> </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 --> <!-- How Ghost Guild Works -->
<section class="py-20 bg-[--ui-bg-elevated]"> <section class="py-20 bg-[--ui-bg-elevated]">
<UContainer> <UContainer>
@ -361,10 +422,10 @@
Circle-Specific Guidance Circle-Specific Guidance
</h3> </h3>
<ul class="text-[--ui-text] space-y-2"> <ul class="text-[--ui-text] space-y-2">
<li>Curated resources for your stage</li> <li>Resources for your stage</li>
<li>Connection with peers on similar journeys</li> <li>Connection with peers</li>
<li>Relevant workshop recommendations</li> <li>Workshop recommendations</li>
<li>Targeted support for your challenges</li> <li>Support for your challenges</li>
</ul> </ul>
</div> </div>
</div> </div>
@ -453,6 +514,14 @@ import {
getContributionTierByValue, getContributionTierByValue,
} from "~/config/contributions"; } from "~/config/contributions";
// Auth state
const { isAuthenticated, memberData, checkMemberStatus } = useAuth();
// Check authentication status on mount
onMounted(async () => {
await checkMemberStatus();
});
// Form state // Form state
const form = reactive({ const form = reactive({
email: "", email: "",
@ -492,7 +561,6 @@ const {
verifyPayment, verifyPayment,
cleanup: cleanupHelcimPay, cleanup: cleanupHelcimPay,
} = useHelcimPay(); } = useHelcimPay();
const { checkMemberStatus } = useAuth();
// Form validation // Form validation
const isFormValid = computed(() => { const isFormValid = computed(() => {

View file

@ -173,6 +173,19 @@
<h2 class="text-xl font-bold text-ghost-100 ethereal-text"> <h2 class="text-xl font-bold text-ghost-100 ethereal-text">
Your Upcoming Events Your Upcoming Events
</h2> </h2>
<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 <UButton
to="/events" to="/events"
variant="ghost" variant="ghost"
@ -182,6 +195,7 @@
Browse All Events Browse All Events
</UButton> </UButton>
</div> </div>
</div>
</template> </template>
<div v-if="loadingEvents" class="text-center py-8"> <div v-if="loadingEvents" class="text-center py-8">
@ -261,6 +275,46 @@
Browse Events Browse Events
</UButton> </UButton>
</div> </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> </UCard>
</div> </div>
</UContainer> </UContainer>
@ -272,6 +326,33 @@ const { memberData, checkMemberStatus } = useAuth();
const registeredEvents = ref([]); const registeredEvents = ref([]);
const loadingEvents = ref(false); 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 // Handle authentication check on page load
const { pending: authPending } = await useLazyAsyncData( const { pending: authPending } = await useLazyAsyncData(

View file

@ -343,20 +343,6 @@
Peer Support Peer Support
</h2> </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"> <div class="space-y-6">
<!-- Enable Toggle --> <!-- Enable Toggle -->
<div class="flex items-start gap-4"> <div class="flex items-start gap-4">
@ -416,7 +402,7 @@
!formData.peerSupportSkillTopics?.includes(t), !formData.peerSupportSkillTopics?.includes(t),
)" )"
:key="tag" :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)" @click="addSuggestedSkillTopic(tag)"
> >
{{ tag }} {{ tag }}
@ -640,9 +626,6 @@
<p class="font-medium text-ghost-100"> <p class="font-medium text-ghost-100">
{{ tier.label }} {{ tier.label }}
</p> </p>
<p class="text-sm text-ghost-400 mt-1">
{{ tier.features[0] }}
</p>
</div> </div>
<div <div
v-if="selectedContributionTier === tier.value" v-if="selectedContributionTier === tier.value"

View file

@ -66,7 +66,7 @@
<button <button
v-if="availableSkills && availableSkills.length > 10" v-if="availableSkills && availableSkills.length > 10"
type="button" type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300" class="px-3 py-1 text-sm text-primary hover:text-primary-600"
@click="showAllSkills = !showAllSkills" @click="showAllSkills = !showAllSkills"
> >
{{ {{
@ -104,7 +104,7 @@
<button <button
v-if="availableTopics && availableTopics.length > 10" v-if="availableTopics && availableTopics.length > 10"
type="button" type="button"
class="px-3 py-1 text-sm text-purple-400 hover:text-purple-300" class="px-3 py-1 text-sm text-primary hover:text-primary-600"
@click="showAllTopics = !showAllTopics" @click="showAllTopics = !showAllTopics"
> >
{{ {{
@ -134,7 +134,7 @@
{{ circleLabels[selectedCircle] }} {{ circleLabels[selectedCircle] }}
<button <button
type="button" type="button"
class="hover:text-purple-200" class="hover:text-primary"
@click="clearCircleFilter" @click="clearCircleFilter"
> >
× ×
@ -147,7 +147,7 @@
Offering Peer Support Offering Peer Support
<button <button
type="button" type="button"
class="hover:text-purple-200" class="hover:text-primary"
@click="clearPeerSupportFilter" @click="clearPeerSupportFilter"
> >
× ×
@ -156,7 +156,7 @@
<button <button
v-if="selectedSkills.length > 0 || selectedTopics.length > 0" v-if="selectedSkills.length > 0 || selectedTopics.length > 0"
type="button" type="button"
class="text-purple-400 hover:text-purple-300" class="text-primary hover:text-primary-600"
@click="clearAllFilters" @click="clearAllFilters"
> >
Clear all filters 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) => { export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id'); const id = getRouterParam(event, "id");
const body = await readBody(event); const body = await readBody(event);
const { email } = body; const { email } = body;
if (!email) { if (!email) {
throw createError({ throw createError({
statusCode: 400, statusCode: 400,
statusMessage: 'Email is required' statusMessage: "Email is required",
}); });
} }
@ -24,22 +25,31 @@ export default defineEventHandler(async (event) => {
if (!eventDoc) { if (!eventDoc) {
throw createError({ throw createError({
statusCode: 404, statusCode: 404,
statusMessage: 'Event not found' statusMessage: "Event not found",
}); });
} }
// Find the registration index // Find the registration index
const registrationIndex = eventDoc.registrations.findIndex( const registrationIndex = eventDoc.registrations.findIndex(
registration => registration.email.toLowerCase() === email.toLowerCase() (registration) =>
registration.email.toLowerCase() === email.toLowerCase(),
); );
if (registrationIndex === -1) { if (registrationIndex === -1) {
throw createError({ throw createError({
statusCode: 404, 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 // Remove the registration
eventDoc.registrations.splice(registrationIndex, 1); eventDoc.registrations.splice(registrationIndex, 1);
@ -48,13 +58,26 @@ export default defineEventHandler(async (event) => {
await eventDoc.save(); 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 { return {
success: true, success: true,
message: 'Registration cancelled successfully', message: "Registration cancelled successfully",
registeredCount: eventDoc.registeredCount registeredCount: eventDoc.registeredCount,
}; };
} catch (error) { } catch (error) {
console.error('Error cancelling registration:', error); console.error("Error cancelling registration:", error);
// Re-throw known errors // Re-throw known errors
if (error.statusCode) { if (error.statusCode) {
@ -63,7 +86,7 @@ export default defineEventHandler(async (event) => {
throw createError({ throw createError({
statusCode: 500, 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 Event from "../../../models/event.js";
import Member from "../../../models/member.js"; import Member from "../../../models/member.js";
import { connectDB } from "../../../utils/mongoose.js"; import { connectDB } from "../../../utils/mongoose.js";
import { sendEventRegistrationEmail } from "../../../utils/resend.js";
import mongoose from "mongoose"; import mongoose from "mongoose";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
@ -102,7 +103,7 @@ export default defineEventHandler(async (event) => {
} }
// Add registration // Add registration
eventData.registrations.push({ const registration = {
memberId: member ? member._id : null, memberId: member ? member._id : null,
name: body.name, name: body.name,
email: body.email.toLowerCase(), email: body.email.toLowerCase(),
@ -112,13 +113,20 @@ export default defineEventHandler(async (event) => {
amountPaid: 0, amountPaid: 0,
dietary: body.dietary || false, dietary: body.dietary || false,
registeredAt: new Date(), registeredAt: new Date(),
}); };
eventData.registrations.push(registration);
// Save the updated event // Save the updated event
await eventData.save(); await eventData.save();
// TODO: Send confirmation email using Resend // Send confirmation email using Resend
// await sendEventRegistrationEmail(body.email, eventData) try {
await sendEventRegistrationEmail(registration, eventData);
} catch (emailError) {
// Log error but don't fail the registration
console.error("Failed to send confirmation email:", emailError);
}
return { return {
success: true, 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", label: "$0 - I need support right now",
tier: "free", tier: "free",
helcimPlanId: null, // No Helcim plan needed for free tier helcimPlanId: null, // No Helcim plan needed for free tier
features: ["Access to basic resources", "Community forum access"],
}, },
SUPPORTER: { SUPPORTER: {
value: "5", value: "5",
amount: 5, amount: 5,
label: "$5 - I can contribute a little", label: "$5 - I can contribute",
tier: "supporter", tier: "supporter",
helcimPlanId: 20162, helcimPlanId: 20162,
features: [
"All Free Membership benefits",
"Priority community support",
"Early access to events",
],
}, },
MEMBER: { MEMBER: {
value: "15", value: "15",
@ -29,12 +23,6 @@ export const CONTRIBUTION_TIERS = {
label: "$15 - I can sustain the community", label: "$15 - I can sustain the community",
tier: "member", tier: "member",
helcimPlanId: 21596, helcimPlanId: 21596,
features: [
"All Supporter benefits",
"Access to premium workshops",
"Monthly 1-on-1 sessions",
"Advanced resource library",
],
}, },
ADVOCATE: { ADVOCATE: {
value: "30", value: "30",
@ -42,12 +30,6 @@ export const CONTRIBUTION_TIERS = {
label: "$30 - I can support others too", label: "$30 - I can support others too",
tier: "advocate", tier: "advocate",
helcimPlanId: 21597, helcimPlanId: 21597,
features: [
"All Member benefits",
"Weekly group mentoring",
"Access to exclusive events",
"Direct messaging with experts",
],
}, },
CHAMPION: { CHAMPION: {
value: "50", value: "50",
@ -55,13 +37,6 @@ export const CONTRIBUTION_TIERS = {
label: "$50 - I want to sponsor multiple members", label: "$50 - I want to sponsor multiple members",
tier: "champion", tier: "champion",
helcimPlanId: 21598, 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 };
}
}