Lots of UI fixes
This commit is contained in:
parent
1f7a0f40c0
commit
e8e3b84276
24 changed files with 3652 additions and 1770 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
};
|
};
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(() => {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
62
server/api/admin/series.put.js
Normal file
62
server/api/admin/series.put.js
Normal 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'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -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",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
114
server/api/members/my-calendar.get.js
Normal file
114
server/api/members/my-calendar.get.js
Normal 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");
|
||||||
|
}
|
||||||
|
|
@ -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
263
server/utils/resend.js
Normal 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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue